Compare commits
10
Commits
4176165ec7
...
2a186c9acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a186c9acc | ||
|
|
a551fa5355 | ||
|
|
4d94635bb2 | ||
|
|
dc92b4cc8f | ||
|
|
035384131c | ||
|
|
1df830adc4 | ||
|
|
3f38914de0 | ||
|
|
19a9a9012f | ||
|
|
92e73f7baa | ||
|
|
7281a4f2e6 |
@@ -2,160 +2,109 @@
|
||||
name: inkreach-teable
|
||||
description: >-
|
||||
Operate a deployed inkreach-teable instance (a Teable fork with a self-built
|
||||
automation engine). Configure tables, fields, records, views, and automations
|
||||
(8 triggers incl. recordCreated/Updated/Condition, formSubmitted, scheduledTime,
|
||||
buttonClick, webhook; 7 actions: script, sendEmail, httpRequest, createRecord,
|
||||
updateRecord, getRecords, sendNotification), plus secrets, execution history, and dynamic plugins
|
||||
(install/uninstall, base-level mount nodes, developer whitelist, CSP apiOrigins).
|
||||
Trigger when the user wants to manage tables/fields/records/views, build or
|
||||
configure automations/workflows, set up button-field triggers, schedule jobs,
|
||||
create webhooks, inspect run history, install or configure plugins, allow
|
||||
developers to upload plugins, or troubleshoot a deployed inkreach-teable
|
||||
instance - even without saying "inkreach-teable" but referencing Teable-style
|
||||
IDs (bseXXX, tblXXX, fldXXX, recXXX, viwXXX, wflXXX) or ask to configure
|
||||
tables/automation/plugins on the local/deployed instance. Also covers dev/deploy
|
||||
tasks (build, restart systemd services). Uses the inkreach-teable CLI, which
|
||||
targets the project's OWN automation contract (NOT the official teable CLI).
|
||||
automation engine) via the inkreach-teable CLI: configure tables, fields,
|
||||
records, views, and automations (8 triggers incl. recordCreated/Updated/
|
||||
Condition, formSubmitted, scheduledTime, buttonClick, webhook; 7 actions),
|
||||
inspect field constraints (select choices, visibilityFilter conditions),
|
||||
manage secrets, execution history, plugins, and record subscriptions. Use
|
||||
when the user references Teable-style IDs (bseXXX, tblXXX, fldXXX, recXXX,
|
||||
viwXXX, wflXXX) or wants to configure tables/automations/plugins on the
|
||||
deployed instance — even without saying "inkreach-teable". The official
|
||||
teable CLI does NOT work here (different automation contract).
|
||||
---
|
||||
|
||||
# inkreach-teable operations
|
||||
|
||||
This skill operates a deployed inkreach-teable instance via the `inkreach-teable` CLI.
|
||||
Operate a deployed inkreach-teable instance via the `inkreach-teable` CLI.
|
||||
Respond in the user's language. Keep answers concise and action-oriented.
|
||||
|
||||
> **Critical**: inkreach-teable's automation engine is **self-built and differs from
|
||||
> official teable**. The official `teable` CLI does NOT work for automations here.
|
||||
> Always use the `inkreach-teable` CLI. See [Key Constraints](#3-key-constraints) for the
|
||||
> exact key-name differences.
|
||||
> **Critical**: the automation engine is **self-built and differs from official
|
||||
> teable** — the official `teable` CLI corrupts or 404s on automations here.
|
||||
> Exact key-name differences are in [Key Constraints](#3-key-constraints).
|
||||
|
||||
## 1. Prerequisites & Constraints
|
||||
## 1. Prerequisites
|
||||
|
||||
- All operations use the `inkreach-teable` CLI. Install via `bash scripts/install.sh` (relative to this skill dir) if `inkreach-teable` is not found.
|
||||
- **Endpoint + token**: configure once with `inkreach-teable auth login --endpoint <url> --token <PAT>`. Default endpoint is `http://localhost:3003`. Verify with `inkreach-teable auth status`.
|
||||
- **Getting a PAT**: `inkreach-teable auth token create --name my-cli` (returns the full token **only once**). Scopes default to automation+table+record read/write. Or create via the web UI (Settings → Personal Access Tokens).
|
||||
- **`--base-id`**: omit if a default was set via `auth login`; CLI uses it automatically. Ask user only if a command fails with MISSING_BASE_ID.
|
||||
- **`--json` mode**: add `--json` to any command for raw JSON output (for agent parsing).
|
||||
- **Unfamiliar commands**: `inkreach-teable <cmd> --help` as a fallback.
|
||||
- **Read the relevant guide before acting**: after entering a module, read the doc(s) marked "Required" in the module map.
|
||||
- Install the CLI if missing: `bash scripts/install.sh` (relative to this skill dir).
|
||||
- **Configure once**: `inkreach-teable auth login --endpoint http://localhost:3003 --token <PAT> [--base-id bseXXX]`; verify with `auth status`. PAT comes from `auth token create --name x` (full token shown **once**) or the web UI (Settings → Personal Access Tokens).
|
||||
- `--json` on any command returns raw JSON (for agent parsing). Fallback for unknown commands: `inkreach-teable <cmd> --help`.
|
||||
- **Read on demand**: consult the guide linked for your module when doing non-trivial work — no need to read everything up front.
|
||||
|
||||
## 2. Module & Command Navigation
|
||||
|
||||
### 2.1 Module Map
|
||||
|
||||
| Module | What it solves | Entry commands | Guide to read |
|
||||
|--------|---------------|----------------|---------------|
|
||||
| Tables & Fields | Create/modify table structure | `table create`, `field create` | [tables-fields.md](guides/tables-fields.md), [field-types.md](api-reference/field-types.md) |
|
||||
| Records | Read/write row data | `record get`, `record create/update/delete` | [cli-reference.md § record](guides/cli-reference.md#base--table--field--record--view) |
|
||||
| Views | Persistent filtered/sorted perspectives | `view create/update` | [cli-reference.md § view](guides/cli-reference.md#base--table--field--record--view) |
|
||||
| Module | What it solves | Entry commands | Detail doc |
|
||||
|--------|---------------|----------------|------------|
|
||||
| Tables & Fields | Create/modify structure; **inspect select-choice constraints** | `table create`, `field create`, `field list --with-options`, `field get` | [tables-fields.md](guides/tables-fields.md), [field-types.md](api-reference/field-types.md) |
|
||||
| Records | Read/write row data | `record get`, `record create/update/delete` | [tables-fields.md § Records](guides/tables-fields.md) |
|
||||
| Views | Filtered/sorted perspectives | `view create/update` | [tables-fields.md § Views](guides/tables-fields.md) |
|
||||
| View presets | View-scoped shared filter/sort bundles + per-user personal config, applied via toolbar | `preset create/update/list/delete` | [tables-fields.md § View Presets](guides/tables-fields.md) |
|
||||
| Automation | Event-driven workflows | `workflow *`, `trigger set`, `action add` | [automation-guide.md](guides/automation-guide.md), [trigger-types.md](api-reference/trigger-types.md), [action-types.md](api-reference/action-types.md) |
|
||||
| Secrets | Store API keys for script actions | `secret set/list/delete` | [automation-guide.md § Secrets](guides/automation-guide.md#secrets) |
|
||||
| Execution History | Inspect run results / rerun failures | `runs list/get/rerun` | [cli-reference.md § runs](guides/cli-reference.md#runs) |
|
||||
| Manual Trigger | Test by firing button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md#testing--triggering) |
|
||||
| SQL | Read-only analytics, JOINs, aggregation | `sql <sql>` | [cli-reference.md § sql](guides/cli-reference.md#sql) |
|
||||
| Plugins | Install/manage dynamic plugins, developer whitelist, CSP | `plugin list/install/uninstall`, `plugin dev allow/revoke` | [cli-reference.md § plugin](guides/cli-reference.md#plugin) |
|
||||
| Dev/Deploy | Build, restart services, known gotchas | (systemd / pnpm build) | [development.md](guides/development.md) |
|
||||
| Secrets | API keys for script actions | `secret set/list/delete` | [automation-guide.md § Secrets](guides/automation-guide.md) |
|
||||
| Execution History | Run results / rerun failures | `runs list/get/rerun` | [automation-guide.md § Testing](guides/automation-guide.md) |
|
||||
| Manual Trigger | Test button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md) |
|
||||
| SQL | Read-only analytics, JOINs | `sql '<sql>'` | [cli-reference.md § sql](guides/cli-reference.md) |
|
||||
| Plugins | Dynamic plugins, dev whitelist, CSP | `plugin *` | [cli-reference.md § plugin](guides/cli-reference.md) |
|
||||
| Subscriptions | Record digest/instant email | `subscription *` | [subscription.md](guides/subscription.md) |
|
||||
| Dev/Deploy | Build, restart, gotchas | systemd / pnpm | [development.md](guides/development.md) |
|
||||
|
||||
### 2.2 Routing Rules
|
||||
Full command syntax: [cli-reference.md](guides/cli-reference.md). Instance access & credentials: [instance-access.md](guides/instance-access.md).
|
||||
|
||||
> **Before executing**: read the documents marked "Required" in the guide for the module.
|
||||
### Routing rules
|
||||
|
||||
| User intent | Correct module | Do NOT do this |
|
||||
|-------------|---------------|----------------|
|
||||
| Configure an automation | Automation: `workflow create` → `trigger set` → `action add` → `activate` | Manually editing DB rows |
|
||||
| React to new records | Trigger `recordCreated` | Polling loop in a script |
|
||||
| React to field changes | Trigger `recordUpdated` with `--field-ids` | `recordCondition` (ignores fields) |
|
||||
| User intent | Correct approach | Do NOT |
|
||||
|-------------|------------------|--------|
|
||||
| Configure an automation | `workflow create` → `trigger set` → `action add` → `activate` | Editing DB rows |
|
||||
| React to new records / field changes | `recordCreated` / `recordUpdated --field-ids` | Polling in a script; `recordCondition` ignores fields |
|
||||
| React to a condition matching | Trigger `recordCondition` (NOT recordMatchesConditions) | Official-teable key names |
|
||||
| Button click starts a workflow | Trigger `buttonClick` + a Button-type field | Any other trigger type |
|
||||
| Receive external HTTP | Trigger `webhook`; fire via `fire webhook` | Expecting auth (backend ignores it) |
|
||||
| Schedule recurring job | Trigger `scheduledTime` (only type+interval work) | Passing triggerTime/weekdays (backend ignores) |
|
||||
| Per-row AI / transformation | Action `script` reading `triggerInput` | Manual row-by-row via record API |
|
||||
| Send notification | Action `sendNotification` (system in-app) | `sendEmail` (needs SMTP) unless email is truly required |
|
||||
| Add a custom UI / view / panel | Plugins: `plugin install --position base` | Manually editing DB rows |
|
||||
| Allow a user to upload plugins | Plugins: `plugin dev allow <userIdOrEmail>` (admin only) | — |
|
||||
| Plugin needs external API access | Plugins: declare `apiOrigins` in `plugin.json` manifest | Editing CSP in next.config.js (global) |
|
||||
| Read records for later writes | `record get` (returns record IDs) | `sql` (no record IDs returned) |
|
||||
| Analytics / aggregation / JOIN | `sql` with GROUP BY | Fetch all records + compute in code |
|
||||
| Store an API key for a script | `secret set` → read in script via `process.env.NAME` | Hardcoding in script code |
|
||||
| Inspect why a workflow didn't run | `runs list --status failed` → `runs get` | Guessing; re-activating blindly |
|
||||
|
||||
### 2.3 Quick Syntax
|
||||
|
||||
```bash
|
||||
# Configure access (once)
|
||||
inkreach-teable auth login --endpoint http://localhost:3003 --token <PAT>
|
||||
|
||||
# Create a table
|
||||
inkreach-teable table create --name "Tasks" --base-id bseXXX \
|
||||
--fields '[{"name":"Title","type":"singleLineText"},{"name":"Status","type":"singleSelect","options":{"choices":["Todo","Done"]}}]'
|
||||
|
||||
# Create a workflow + set buttonClick trigger + add a script action + activate
|
||||
inkreach-teable workflow create --name "On Go click" --base-id bseXXX
|
||||
inkreach-teable trigger set wflXXX --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]'
|
||||
inkreach-teable action add wflXXX --type script --code 'return triggerInput.record.fields'
|
||||
inkreach-teable workflow activate wflXXX
|
||||
|
||||
# Test by firing the button
|
||||
inkreach-teable fire button-click tblXXX recXXX fldXXX
|
||||
inkreach-teable runs list wflXXX
|
||||
```
|
||||
|
||||
For complete syntax and all options, read [cli-reference.md](guides/cli-reference.md).
|
||||
| Button click starts a workflow | `buttonClick` trigger + Button field (`options.workflow` object) | Other trigger types |
|
||||
| Receive external HTTP | `webhook`; test via `fire webhook` | Expecting auth (backend ignores it) |
|
||||
| Schedule recurring job | `scheduledTime` (only `timing.type` + `interval` honored) | triggerTime/weekdays (ignored) |
|
||||
| Per-row transformation | `script` action reading `triggerInput` | Row-by-row record API |
|
||||
| Send notification | `sendNotification` (in-app) | `sendEmail` (needs SMTP) unless email required |
|
||||
| **Inspect select-choice constraints** | `field list --with-options`, `field get <tbl> <name>`, `field visible-choices` | `--json` + manual filtering |
|
||||
| Read records for later writes | `record get` (returns record IDs) | `sql` (no record IDs) |
|
||||
| Analytics / JOIN | `sql` with GROUP BY | Fetch all records + compute |
|
||||
| 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 |
|
||||
|
||||
## 3. Key Constraints
|
||||
|
||||
These differ from official teable and from the (stale) `docs/automation/triggers.md`. **Use these exact values:**
|
||||
These differ from official teable and from the (stale) `docs/automation/triggers.md`.
|
||||
|
||||
1. **Filter key is `conditions`** (NOT `filter`). Pass via `trigger set --conditions '<json>'`. The JSON shape is `{conjunction:"and"|"or", filterSet:[{fieldId, operator, value}]}`.
|
||||
2. **Watch-fields key is `fieldIds`** (NOT `watchFieldIds`). For `recordUpdated`/`buttonClick`. `[]` means watch all fields.
|
||||
3. **Trigger category is `recordCondition`** (NOT `recordMatchesConditions`). It triggers on create+update when the record matches conditions.
|
||||
4. **`scheduledTime` backend honors only `timing.type` + `timing.interval`**. `triggerTime`, `weekdays`, `daysOfMonth`, `starting`, `ending`, `tz` are all IGNORED by the backend. `weeks` type is hardcoded to every Sunday. Don't configure fields the backend won't use.
|
||||
5. **Webhook real route is `/api/webhook/{baseId}/{workflowId}`** (no `/base/` or `/workflow/` path segments), and `authorization` config is UI-only — **backend does NOT validate it**. Fire via `inkreach-teable fire webhook <baseId> <wfId>`.
|
||||
6. **Node updates use whole-graph replacement** (`PUT /node`). CLI commands (`trigger set`, `action add/update/delete`) handle GET→modify→PUT internally — always use them, never hand-edit partial graphs.
|
||||
7. **`activate` creates a snapshot**: changes to nodes only take effect after re-activating. `hasDraft: true` means unactivated changes exist.
|
||||
8. **`workflow delete` is a SOFT delete** (cascades to base_node, auto-token, trigger unregister). Test workflows should be `deactivate`d, not deleted.
|
||||
9. **Secrets values are write-only** — `secret list` never returns the value. Scripts read them via `process.env.<NAME>`.
|
||||
10. **PAT format is `teable_{id}_{encryptedSign}`** — create via `auth token create`, the full string is returned **once**.
|
||||
11. **Script sandbox has NO `fs`/`eval`/`Function`**, but has `triggerInput`, `console`, `fetch`, `process.env` (= secrets + `AUTOMATION_TOKEN` + `PUBLIC_ORIGIN`), and a controlled `require`. Use `triggerInput._accessToken` + `triggerInput._apiBaseUrl` to call back into the instance API. **npm deps supported**: declare via `action add/update --dependencies '[{"name":"axios","version":"1.6.0"}]'` (or shorthand `axios@1.6.0,dayjs`), then `require('axios')` works in-sandbox. **Requires** `npm` on the backend service PATH (see [action-types.md](api-reference/action-types.md) §script + [deployment/systemd.md](../../../../deployment/systemd.md) Troubleshooting) — missing it → `spawn npm ENOENT`.
|
||||
1. **Filter key is `conditions`** (NOT `filter`): `trigger set --conditions '<json>'`, shape `{conjunction, filterSet:[{fieldId, operator, value}]}`.
|
||||
2. **Watch-fields key is `fieldIds`** (NOT `watchFieldIds`); `[]` = watch all fields.
|
||||
3. **Trigger category `recordCondition`** (NOT `recordMatchesConditions`).
|
||||
4. **`scheduledTime` honors only `timing.type` + `timing.interval`** — triggerTime/weekdays/daysOfMonth/starting/ending/tz are ignored; `weeks` is hardcoded to Sunday.
|
||||
5. **Webhook route is `/api/webhook/{baseId}/{workflowId}`** (no `/base/` segment); `authorization` config is UI-only, backend never validates. Fire: `fire webhook <baseId> <wfId>`.
|
||||
6. **Node updates are whole-graph replacement** — `trigger set` / `action add/update/delete` do GET→modify→PUT internally; never hand-edit partial graphs.
|
||||
7. **`activate` creates a snapshot** — node changes take effect only after re-activating. `hasDraft: true` = unactivated changes.
|
||||
8. **`workflow delete` is soft** (cascades to nodes, tokens, trigger unregister); test workflows should be `deactivate`d.
|
||||
9. **Secrets are write-only** — `secret list` never returns values; scripts read `process.env.<NAME>`.
|
||||
10. **Button field binding**: `options.workflow` is an **object** `{id, name?}`, not a bare id string. The field's `isActive` snapshot can go stale; the API validates the workflow's DB `isActive` at click time (deactivated → 400).
|
||||
11. **Script sandbox** has NO `fs`/`eval`/`Function`, but has `triggerInput`, `console`, `fetch`, `process.env` (secrets + `AUTOMATION_TOKEN` + `PUBLIC_ORIGIN`), and a controlled `require`. Call back into the instance via `triggerInput._accessToken` + `triggerInput._apiBaseUrl`. **npm deps**: declare with `action add/update --dependencies '[{"name":"axios","version":"1.6.0"}]'` (or `axios@1.6.0,dayjs`). Requires `npm` on the backend **service** PATH — systemd user units default to a PATH that omits the nvm bin dir, so deps fail with `spawn npm ENOENT`; fix by adding `Environment=PATH=<nvm-bin>:/usr/local/sbin:...` to `teable-backend.service`.
|
||||
12. **Field options updates go through the convert API** — `field update --options` handles this; the plain PATCH update API strips `options`.
|
||||
|
||||
## 4. Execution Rules
|
||||
## 4. Quick Syntax
|
||||
|
||||
### 4.1 Standard Order
|
||||
|
||||
1. **Confirm context** — identify target base/table/workflow (`--base-id`, IDs in user's message).
|
||||
2. **Read before write** — `table get` / `field list` / `workflow get` to confirm current state.
|
||||
3. **Execute changes** — create/update via CLI commands.
|
||||
4. **Verify** — re-read; for automations, `activate` then `runs list` after a test fire.
|
||||
|
||||
### 4.2 Critical Rules (with reasoning)
|
||||
|
||||
1. **Use `inkreach-teable` CLI, never `teable` CLI for automations** — official CLI uses incompatible contract (filter/watchFieldIds/recordMatchesConditions) and will 404 or corrupt config.
|
||||
2. **Read [trigger-types.md](api-reference/trigger-types.md) before configuring a trigger** — contains exact config keys per trigger; guessing leads to silent misfires.
|
||||
3. **Build the full chain then activate** — `workflow create` → `trigger set` → `action add` → `activate`. Editing nodes after activation requires re-`activate` to snapshot.
|
||||
4. **For `buttonClick`, ensure the field is a Button field bound to this workflow** — create via `field create --type button` with `options.workflow.id` (the `workflow` value is an **object** `{id, name?}`, NOT a bare `workflowId` string — matching the DB shape), or the field won't render/click. Note: field `options` captures a stale `isActive` snapshot at creation; the API validates the DB `isActive` at click time instead, so a deactivated workflow returns 400.
|
||||
5. **Read [action-types.md](api-reference/action-types.md) before writing scripts** — covers sandbox globals, variable references `{{nodeId.path}}`, and the `_accessToken`/`_apiBaseUrl` back-channel.
|
||||
```bash
|
||||
inkreach-teable auth login --endpoint http://localhost:3003 --token <PAT> # once
|
||||
inkreach-teable table create --name "Tasks" --fields '[{"name":"Title","type":"text"},{"name":"Status","type":"singleSelect","options":{"choices":["Todo","Done"]}}]'
|
||||
inkreach-teable workflow create --name "On Go click" # → wflXXX
|
||||
inkreach-teable trigger set wflXXX --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]'
|
||||
inkreach-teable action add wflXXX --type script --code 'return triggerInput.record.fields'
|
||||
inkreach-teable workflow activate wflXXX
|
||||
inkreach-teable fire button-click tblXXX recXXX fldXXX && inkreach-teable runs list wflXXX
|
||||
```
|
||||
|
||||
## 5. Common Errors & Recovery
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `MISSING_BASE_ID` | No base id resolved | Pass `--base-id`, or `auth login --base-id <id>` |
|
||||
| `401` / "access denied" | Token invalid/expired or lacks scope | `auth status`; recreate PAT with needed scopes |
|
||||
| `403` | Base role lacks permission | User must grant role (owner/creator) in web UI |
|
||||
| Workflow never fires | Not activated, or trigger misconfigured | `workflow get` → check `isActive`; verify trigger type + tableId/fieldIds |
|
||||
| `scheduledTime` fires at wrong time | Backend ignores most timing fields | Only `timing.type`+`interval` work; use cron-friendly intervals |
|
||||
| button-click returns 400 "not active" | Workflow deactivated since field created | `workflow activate <id>`, or field options are stale |
|
||||
| `runs list` shows `failed` | Action error | `runs get <wfId> <execId>` → read `nodeResults[].error` |
|
||||
| Secret not readable in script | Wrong env var name | Scripts use `process.env.<NAME>` where NAME = secret name |
|
||||
|
||||
## 6. API Reference Index
|
||||
|
||||
Files in `api-reference/`, read when you need exact config formats:
|
||||
|
||||
- **[trigger-types.md](api-reference/trigger-types.md)** — all 8 triggers: type string, config keys, output variables.
|
||||
- **[action-types.md](api-reference/action-types.md)** — all 7 actions: config keys, sandbox globals, variable references.
|
||||
- **[field-types.md](api-reference/field-types.md)** — Teable field types, aliases, and option shapes (incl. Button field with `workflowId`).
|
||||
|
||||
## 7. Instance Access
|
||||
|
||||
Endpoint, service status, and credential-acquisition steps: see [instance-access.md](guides/instance-access.md).
|
||||
For dev/deploy (build, restart systemd, known gotchas): see [development.md](guides/development.md).
|
||||
| `MISSING_BASE_ID` | No base id resolved | Pass `--base-id`, or `auth login --base-id` |
|
||||
| `401` / access denied | Token invalid/expired or lacks scope | `auth status`; recreate PAT |
|
||||
| `403` | Base role too low | Grant owner/creator role in web UI |
|
||||
| Workflow never fires | Not activated / trigger misconfigured | `workflow get` → `isActive`; verify trigger type + tableId/fieldIds |
|
||||
| `scheduledTime` wrong time | Backend ignores most timing fields | Only `timing.type`+`interval` work |
|
||||
| button-click 400 "not active" | Workflow deactivated since field created | `workflow activate <id>` |
|
||||
| `runs list` shows failed | Action error | `runs get <wfId> <execId>` → `nodeResults[].error` |
|
||||
| Script deps fail `spawn npm ENOENT` | `npm` not on service PATH | See Key Constraint 11 |
|
||||
| Record write 400 "does not meet the current row conditions" | Value violates choice `visibilityFilter` | `field visible-choices` to see allowed options |
|
||||
|
||||
@@ -83,7 +83,7 @@ inkreach-teable action update <wfId> <nodeId> --dependencies '[{"name":"axios"}]
|
||||
inkreach-teable action update <wfId> <nodeId> --dependencies '[]' # clear
|
||||
```
|
||||
|
||||
> ⚠️ **Deployment requirement**: the backend installs deps via `spawn('npm')`, so `npm` must be on the **service** PATH. systemd user units default to a PATH that omits the nvm bin dir → scripts with deps fail with `spawn npm ENOENT`. Fix: add `Environment=PATH=<nvm-bin>:/usr/local/sbin:...` to `teable-backend.service` (see [deployment/systemd.md](../../../../deployment/systemd.md) Troubleshooting).
|
||||
> ⚠️ **Deployment requirement**: the backend installs deps via `spawn('npm')`, so `npm` must be on the **service** PATH. systemd user units default to a PATH that omits the nvm bin dir → scripts with deps fail with `spawn npm ENOENT`. Fix: add `Environment=PATH=<nvm-bin>:/usr/local/sbin:...` to `teable-backend.service`.
|
||||
|
||||
**Config:**
|
||||
```json
|
||||
|
||||
@@ -4,7 +4,7 @@ Complete reference of all Teable field types, aliases, option shapes, and smart
|
||||
|
||||
> ✅ **Verified 2026-08**: aliases, string choices, compact `--fields` format, smart
|
||||
> inference, and name resolution (`foreignTableName`/`linkFieldName`/`lookupFieldName`)
|
||||
> are implemented in the CLI (`packages/inkreach-cli/src/lib/field-normalize.ts`)
|
||||
> are implemented in the CLI (`src/lib/field-normalize.ts`, repo root)
|
||||
> and covered by unit tests (`field-normalize.spec.ts`, 20 cases).
|
||||
|
||||
---
|
||||
@@ -320,6 +320,42 @@ inkreach-teable field create --table-id tblXXX --name "Delete" --type button \
|
||||
|
||||
---
|
||||
|
||||
### Last Modified Time (tracked-field references)
|
||||
|
||||
A `lastModifiedTime` field can track **only specific fields** instead of all of them, so
|
||||
the timestamp resets when a referenced field changes but stays put on ordinary edits.
|
||||
Write the references inside the `expression` option:
|
||||
|
||||
```bash
|
||||
# Track by field name — resets only when 流转状态 or 流转角色 change
|
||||
inkreach-teable field create --table-id tblXXX --name "流转时间" --type lastModifiedTime \
|
||||
--options '{"expression":"LAST_MODIFIED_TIME([流转状态],[流转角色])"}'
|
||||
|
||||
# Track by field id (mixed syntax also allowed)
|
||||
inkreach-teable field create --table-id tblXXX --name "流转时间" --type lastModifiedTime \
|
||||
--options '{"expression":"LAST_MODIFIED_TIME({fldAbc123},{fldDef456})"}'
|
||||
```
|
||||
|
||||
| Reference syntax | Meaning |
|
||||
|---|---|
|
||||
| `LAST_MODIFIED_TIME()` | Track **all** fields (default) |
|
||||
| `LAST_MODIFIED_TIME([Name])` | Reference a field by its name |
|
||||
| `LAST_MODIFIED_TIME({fldId})` | Reference a field by its id |
|
||||
|
||||
**Key behaviors**:
|
||||
|
||||
1. The expression is parsed server-side into `trackedFieldIds` (the field's effective
|
||||
`options.trackedFieldIds`); the stored `expression` is normalized back to the canonical
|
||||
`LAST_MODIFIED_TIME()` form while `trackedFieldIds` carries the references.
|
||||
2. An unresolvable field **name** returns `400 Unknown field referenced in expression: <name>`;
|
||||
unresolvable `{fldId}` values are simply ignored (harmless — never match).
|
||||
3. You can also set `options.trackedFieldIds` directly (an array of field ids); an explicit
|
||||
`trackedFieldIds` wins over the expression when both are present.
|
||||
4. Works on create **and** convert (`PUT /api/table/:tableId/field/:fieldId/convert` with
|
||||
`{"type":"lastModifiedTime","options":{"expression":"..."}}`).
|
||||
|
||||
---
|
||||
|
||||
## Smart Inference
|
||||
|
||||
### Type inference from `--name`
|
||||
|
||||
@@ -68,9 +68,16 @@ inkreach-teable trigger set <wfId> --type recordUpdated --table-id tblXXX \
|
||||
- ⚠️ **`oldFields` is TOP-LEVEL** (`triggerInput.oldFields.fldXXX`), a sibling of `record` — NOT nested inside `record`. Script access: `triggerInput.oldFields?.['fldXXX']`; variable reference: `{{n0.oldFields.fldXXX}}`.
|
||||
- `fieldIds` watch-filter matches against `changedFieldIds`.
|
||||
- On batch mode (`_batch`), each `records[]` entry carries its own `changedFieldIds` + `oldFields`.
|
||||
- ⚠️ **Fires on REST API record updates only** (`PATCH /api/table/{tableId}/record[/{recordId}]`, including programmatic/automation writes). Browser grid cell edits go through ShareDB ops and do **not** fire this trigger.
|
||||
- ⚠️ **Write-path coverage** (verified 2026-08): browser grid cell edits DO fire this trigger — every grid editor commit funnels through `record.updateCell` → the REST `updateRecord` API (no ShareDB op submit exists in the frontend). Covered surfaces: single/batch record update (REST v1+v2), grid paste (small `paste-by-id`, large `paste-*-stream`, range `paste`), grid cell clear (`clear` / `clear-by-id`), record duplicate, and programmatic/automation writes (workflow actions call the REST API). Form submissions intentionally emit ONLY the `formSubmitted` event (independent channel). Record deletions emit no automation event (no delete trigger category exists).
|
||||
- Values resolve to `undefined` (not `[]`) if the emitting path could not snapshot before-values — scripts should guard `changedFieldIds ?? []`.
|
||||
|
||||
**Workflow-write echo suppression** (all 4 record triggers: `recordCreated` / `recordUpdated` / `recordCondition` / `recordCreatedOrUpdated`):
|
||||
- Writes made BY workflow executions (script `fetch` with `triggerInput._accessToken`, `createRecords`/`updateRecords` actions) are tagged `writeSource: {workflowId, executionId}` on the event.
|
||||
- **Self-echoes are always dropped**: a workflow never fires on its own writes (the script's PATCH re-triggering its own workflow was the duplication bug — one edit cascaded into 3-5 executions, every echo ending in a `skipped` no-op guard).
|
||||
- Writes by OTHER workflows still fire by default — cross-workflow chaining is load-bearing (e.g. the order-sync job 建档 and this workflow stamping the initial 待处理). Opted-in events carry `triggerInput.writeSource.workflowId` for loop-guarding inside scripts.
|
||||
- Config `"skipWorkflowWrites": true` additionally drops cross-workflow writes (stronger dedup when a workflow should react ONLY to human edits).
|
||||
- Human grid edits, form/API/CLI writes are never tagged and always fire; suppressed writes still reach other listeners (e.g. record subscriptions) — only record-trigger handlers drop them.
|
||||
|
||||
---
|
||||
|
||||
## 3. recordCondition — When a record matches conditions
|
||||
|
||||
@@ -43,14 +43,16 @@ inkreach-teable table create --name <n> [--base-id <id>] [--fields '<json>']
|
||||
inkreach-teable table update <tableId> --name <n>
|
||||
inkreach-teable table delete <tableId>
|
||||
|
||||
# field (get/update/delete take <tableId> <fieldId> — 2026-08 fix; old /api/field/:id routes never existed)
|
||||
inkreach-teable field list <tableId>
|
||||
inkreach-teable field get <tableId> <fieldId>
|
||||
# field (get/update/visible-choices take <tableId> <fieldIdOrName> — id or display name, auto-resolved)
|
||||
inkreach-teable field list <tableId> [--with-options]
|
||||
# --with-options: OPTIONS summary column + per-choice visibility conditions for select fields
|
||||
inkreach-teable field get <tableId> <fieldIdOrName>
|
||||
# human mode renders select choices with their visibilityFilter conditions; --json for raw
|
||||
inkreach-teable field create --table-id <tid> --name <n> [--type <t|alias>] [--options '<json>'] \
|
||||
[--description <d>] [--lookup-options '<json>'] [--is-lookup]
|
||||
# --type optional: inferred from --options shape / --name when omitted
|
||||
inkreach-teable field update <tableId> <fieldId> [--name <n>] [--options '<json>'] # --options goes via convert API
|
||||
inkreach-teable field visible-choices <tableId> <recordId> <fieldId> # evaluate select-choice visibilityFilter
|
||||
inkreach-teable field update <tableId> <fieldIdOrName> [--name <n>] [--options '<json>'] # --options goes via convert API
|
||||
inkreach-teable field visible-choices <tableId> <recordId> <fieldIdOrName> # evaluate select-choice visibilityFilter
|
||||
inkreach-teable field delete <tableId> <fieldId>
|
||||
|
||||
# record
|
||||
@@ -67,6 +69,13 @@ inkreach-teable view get <viewId>
|
||||
inkreach-teable view create --table-id <tid> --name <n> [--type <t>]
|
||||
inkreach-teable view update <viewId> [--name <n>]
|
||||
inkreach-teable view delete <viewId>
|
||||
|
||||
# view presets (view-scoped shared named filter/sort bundles, applied via toolbar)
|
||||
inkreach-teable preset list <tableId> [--view-id <vid>]
|
||||
inkreach-teable preset create --table-id <tid> --view-id <vid> --name <n> [--description <d>] \
|
||||
[--filter '<json>'] [--sort '<json>'] [--group '<json>'] [--options '<json>'] [--column-meta '<json>']
|
||||
inkreach-teable preset update <presetId> --table-id <tid> [--name <n>] [--description <d>] [--filter '<json>' ...]
|
||||
inkreach-teable preset delete <presetId> --table-id <tid>
|
||||
```
|
||||
|
||||
## workflow
|
||||
@@ -157,6 +166,21 @@ inkreach-teable fire webhook <baseId> <workflowId> [--body '<json>'] [--method <
|
||||
|
||||
Manually trigger for testing. Webhook needs no auth (backend is `@Public`).
|
||||
|
||||
## subscription
|
||||
|
||||
```bash
|
||||
inkreach-teable subscription list
|
||||
inkreach-teable subscription get <subscriptionId>
|
||||
inkreach-teable subscription create --name <n> --table-id <tid> [options]
|
||||
inkreach-teable subscription update <subscriptionId> [options]
|
||||
inkreach-teable subscription delete <subscriptionId>
|
||||
inkreach-teable subscription events <subscriptionId>
|
||||
inkreach-teable subscription send <subscriptionId>
|
||||
```
|
||||
|
||||
Record digest/instant email subscriptions. Options (`--filter`, `--send-mode`,
|
||||
`--schedule-*`, `--trigger-on-enter`, …) and semantics: [subscription.md](subscription.md).
|
||||
|
||||
## sql
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Development & Deployment
|
||||
|
||||
> This guide documents the **inkreach-teable application repo**
|
||||
> (`ssh://git@gitea.inkreach.cc:2222/inkreachhub/inkreach-teable.git`) — the
|
||||
> deployed instance this CLI operates on — not this CLI repo. Paths like
|
||||
> `apps/nestjs-backend/` refer to that repo's checkout.
|
||||
|
||||
How to build, restart, and debug this inkreach-teable instance. Covers the
|
||||
dual-process architecture, known gotchas, and test infrastructure. For daily
|
||||
operations (tables/automation), use the `inkreach-teable` CLI instead — this guide is
|
||||
@@ -53,6 +58,28 @@ untouched (node_modules paths are treated as immutable cache inputs). Symptom: b
|
||||
behavior unchanged after a code change. Fix: `rm apps/nestjs-backend/dist/index.js`
|
||||
then rebuild, and verify with `grep '<your-new-string>' dist/index.js`.
|
||||
|
||||
### Workspace packages must be rebuilt BEFORE the backend (else stale dist ships)
|
||||
Distinct from the cache trap above: `apps/nestjs-backend` never compiles `@teable/*`
|
||||
sources — it resolves `@teable/core`, `@teable/v2-core`, `@teable/openapi` … via
|
||||
`node_modules` symlinks to `packages/*/dist`. `@teable/v2-core` in particular is a
|
||||
webpack EXTERNAL: the running backend reads `packages/v2/core/dist/index.cjs` directly
|
||||
at runtime, so a backend rebuild does not even copy it.
|
||||
|
||||
SILENT FAILURE MODE (hit 2026-08-18, select-defaultValue cascade fix): edit
|
||||
`packages/core/src/…` + `packages/v2/core/src/…` → only rebuild backend + restart →
|
||||
live API keeps the old behavior, no error anywhere. Correct order:
|
||||
|
||||
```bash
|
||||
pnpm --filter @teable/core build # tsc → packages/core/dist/**
|
||||
pnpm --filter @teable/v2-core build # tsdown → dist/index.cjs + dist/index.js
|
||||
cd apps/nestjs-backend && rm -f dist/index.js && pnpm build # bundle + externals
|
||||
# verify the new code actually shipped BEFORE restarting:
|
||||
grep '<new-string>' packages/core/dist/<path>.js
|
||||
grep '<new-string>' packages/v2/core/dist/index.cjs
|
||||
# then the best-path restart (:3003)
|
||||
```
|
||||
(Many packages at once: `pnpm -F './packages/**' build`, then backend build.)
|
||||
|
||||
### Node v24 fetch drops the Authorization header
|
||||
The global `fetch()` on Node v24 (undici) drops the `Authorization` request
|
||||
header in certain cases (redirects, proxies). **Use `node:http`/`node:https`**
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Record Subscriptions
|
||||
|
||||
Digest/instant email subscriptions over table records. Full CRUD via
|
||||
`subscription list/get/create/update/delete/events/send`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
inkreach-teable subscription list
|
||||
inkreach-teable subscription get <subscriptionId>
|
||||
inkreach-teable subscription create --name "Daily escalations" --table-id tblXXX [options]
|
||||
inkreach-teable subscription update <subscriptionId> [options]
|
||||
inkreach-teable subscription delete <subscriptionId>
|
||||
inkreach-teable subscription events <subscriptionId>
|
||||
inkreach-teable subscription send <subscriptionId> # force a digest send now
|
||||
```
|
||||
|
||||
## Options (create/update)
|
||||
|
||||
| Option | Meaning |
|
||||
|--------|---------|
|
||||
| `--name` | Display name |
|
||||
| `--table-id` | Target table |
|
||||
| `--no-watch-created` / `--no-watch-updated` | Exclude create/update events |
|
||||
| `--field-ids '<json>'` | Restrict watched fields |
|
||||
| `--record-ids '<json>'` | Restrict watched records |
|
||||
| `--display-field-id <fld>` | Field shown in the digest row |
|
||||
| `--attach-csv` / `--no-attach-csv` | Attach matching records as CSV |
|
||||
| `--filter '<IFilter json>'` | Scope: only records matching this filter |
|
||||
| `--trigger-on-enter` | Notify only on the transition INTO the filter condition (requires `--filter`); default is state semantics (every matching change) |
|
||||
| `--send-mode digest\|instant` | Digest (batched on schedule) or instant email |
|
||||
| `--recipients '<a@x.com,b@y.com>'` | Email recipients |
|
||||
| `--no-notify-in-app` | Disable in-app notification |
|
||||
| `--schedule-type daily\|weekly\|hours\|minutes` | Digest schedule kind |
|
||||
| `--schedule-time HH:mm` | Daily/weekly fire time |
|
||||
| `--schedule-weekday 0-6` | Weekly day (0 = Sunday) |
|
||||
| `--schedule-interval n` | Interval for hours/minutes modes |
|
||||
| `--inactive` | Create/update in inactive state |
|
||||
|
||||
## Semantics & gotchas
|
||||
|
||||
- `--filter` uses the SAME IFilter shape as view filters. Date values:
|
||||
`{"mode":"exactDate","exactDate":"2026-08-21","timeZone":"Asia/Shanghai"}`.
|
||||
User values are id arrays (`"Me"` resolves to the subscription creator).
|
||||
- Filter fields must be **visible to the creator** (enforced server-side,
|
||||
fail-closed: unknown operator or deleted field never matches).
|
||||
- `--trigger-on-enter` implements "notify only when it starts matching"
|
||||
(e.g. status turns critical); the default state semantics notify on every
|
||||
matching change.
|
||||
@@ -44,20 +44,7 @@ inkreach-teable table create --name "<name>" [--base-id <id>] \
|
||||
| `--fields` | no | Array of field objects (see below) |
|
||||
| `--description` | no | Optional description |
|
||||
|
||||
**`--fields` format**: JSON array of `{name, type, [options], [description]}` objects. Allowed types and aliases:
|
||||
|
||||
| Shorthand | Full type | Shorthand | Full type |
|
||||
|-----------|-----------|-----------|-----------|
|
||||
| `text`, `str` | `singleLineText` | `long` | `longText` |
|
||||
| `num`, `int` | `number` | `check`, `bool` | `checkbox` |
|
||||
| `sel`, `select` | `singleSelect` | `multi`, `tags` | `multipleSelect` |
|
||||
| `date` | `date` | `rate`, `star` | `rating` |
|
||||
| `user`, `person` | `user` | `file`, `attach` | `attachment` |
|
||||
| `link`, `rel` | `link` | `rollup` | `rollup` |
|
||||
| `formula` | `formula` | `auto` | `autoNumber` |
|
||||
| `created` | `createdTime` | `modified` | `lastModifiedTime` |
|
||||
| `createdBy` | `createdBy` | `modifiedBy` | `lastModifiedBy` |
|
||||
| `button` | `button` | `condRollup` | `conditionalRollup` |
|
||||
**`--fields` format**: JSON array of `{name, type, [options], [description]}` objects. Types accept full values or aliases (`sel` → `singleSelect`); per-type option shapes and inference rules: [field-types.md](../api-reference/field-types.md).
|
||||
|
||||
**Compact shorthand** (in `--fields` only): `"name:type"` string format:
|
||||
|
||||
@@ -103,35 +90,10 @@ inkreach-teable field create --table-id <tid> --name "<name>" \
|
||||
|------|----------|-------|
|
||||
| `--table-id` | yes | Target table |
|
||||
| `--name` | yes | Field display name |
|
||||
| `--type` | yes | Full type name or alias (see [field-types.md](../api-reference/field-types.md)) |
|
||||
| `--options` | depends on type | JSON object; see type-specific shapes below |
|
||||
| `--type` | no | Full type name or alias; **omitted → inferred** from `--options` shape or `--name` pattern (rules: [field-types.md § Smart Inference](../api-reference/field-types.md)) |
|
||||
| `--options` | depends on type | JSON object; select `choices` accept plain strings — see [field-types.md § Option Shapes](../api-reference/field-types.md) |
|
||||
| `--description` | no | Optional |
|
||||
|
||||
**Type inference from name**: when `--type` is omitted, the CLI infers from the field name:
|
||||
|
||||
| Name pattern | Inferred type | Name pattern | Inferred type |
|
||||
|-------------|---------------|-------------|---------------|
|
||||
| `status`, `priority`, `stage`, `category`, `type`, `level` | `singleSelect` | `tag`, `label` | `multipleSelect` |
|
||||
| `description`, `notes`, `comment`, `content`, `body` | `longText` | `count`, `amount`, `price`, `quantity`, `total`, `age` | `number` |
|
||||
| `rating`, `score`, `stars` | `rating` | `is*`, `has*`, `active`, `enabled`, `done`, `completed` | `checkbox` |
|
||||
| `assignee`, `owner`, `user` | `user` | `attachment`, `file`, `image`, `photo`, `document` | `attachment` |
|
||||
| `email` | `singleLineText` | `url`, `link` (without table context) | `singleLineText` |
|
||||
| `created time` / `created` | `createdTime` | `last modified` / `updated` | `lastModifiedTime` |
|
||||
| `created by`, `author` | `createdBy` | `modified by`, `updated by` | `lastModifiedBy` |
|
||||
|
||||
**Options by type** (most common):
|
||||
|
||||
| Type | Required options | Shape |
|
||||
|------|-----------------|-------|
|
||||
| `singleSelect` / `multipleSelect` | `choices` | `{"choices":["A","B","C"]}` or `{"choices":[{"name":"A","color":"blue"}]}` — colors auto-assigned if omitted |
|
||||
| `number` | none (defaults integer) | `{"formatting":{"precision":2}}` for decimal |
|
||||
| `rating` | none (defaults max=5, star, yellow) | `{"max":5,"icon":"star","color":"yellowBright"}` |
|
||||
| `date` | none | `{"formatting":{"date":"YYYY-MM-DD","time":"None","timeZone":"UTC"}}` |
|
||||
| `link` | `foreignTableId` / `foreignTableName` | `{"foreignTableId":"tblXXX","relationship":"manyOne"}` |
|
||||
| `formula` | `expression` | `{"expression":"{fieldId}+{otherFieldId}"}` |
|
||||
| `rollup` | via `--lookup-options` | See [field-types.md](../api-reference/field-types.md) |
|
||||
| `button` | `workflow` | `{"label":"Click","color":"blue","workflow":{"id":"wflXXX"}}` |
|
||||
|
||||
**Name resolution**: for `link` fields, use `foreignTableName` instead of `foreignTableId` — the CLI resolves it automatically:
|
||||
|
||||
```bash
|
||||
@@ -142,7 +104,7 @@ inkreach-teable field create --table-id tblXXX --name "Project" --type link \
|
||||
### Update & Delete
|
||||
|
||||
```bash
|
||||
inkreach-teable field update <tableId> <fieldId> [--name "<n>"] [--options '<json>']
|
||||
inkreach-teable field update <tableId> <fieldIdOrName> [--name "<n>"] [--options '<json>']
|
||||
inkreach-teable field delete <tableId> <fieldId>
|
||||
```
|
||||
|
||||
@@ -153,6 +115,27 @@ inkreach-teable field delete <tableId> <fieldId>
|
||||
| `--name` + `--options` | Both applied via one convert call |
|
||||
| `field delete` | Irreversible — all cell data in that field is lost |
|
||||
|
||||
### Inspecting fields & constraints
|
||||
|
||||
`field get` and `field visible-choices` accept **fieldId or field name** (the CLI resolves names automatically). Human mode renders select choices with their conditions — no `--json` post-processing needed:
|
||||
|
||||
```bash
|
||||
# One-field overview: which fields carry conditional choices
|
||||
inkreach-teable field list tblXXX --with-options
|
||||
# ID NAME TYPE OPTIONS
|
||||
# ... 处理反馈 singleSelect 21 choices · 19 conditional
|
||||
# followed by per-choice listings: name [color] + "when: <condition summary>"
|
||||
|
||||
# Single field by name — choices expanded with human-readable conditions
|
||||
inkreach-teable field get tblXXX 处理反馈
|
||||
# choices (21 · 19 conditional):
|
||||
# 待工厂补发 [orange]
|
||||
# when: 问题类型 is 物流问题-超时未揽收 OR 问题类型 is 物流问题-丢件 …
|
||||
|
||||
# What a specific record may currently select
|
||||
inkreach-teable field visible-choices tblXXX recXXX 处理反馈
|
||||
```
|
||||
|
||||
### Select-choice dependency (visibilityFilter) — 2026-08
|
||||
|
||||
Single/multiple-select choices may carry a `visibilityFilter` making them visible only when another select field matches:
|
||||
@@ -162,9 +145,10 @@ Single/multiple-select choices may carry a `visibilityFilter` making them visibl
|
||||
"filterSet":[{"fieldId":"fldDeviceType","operator":"is","value":"choPrinter"}]}}]}
|
||||
```
|
||||
|
||||
- **Read config**: `inkreach-teable field get <tableId> <fieldId>` → `options.choices[].visibilityFilter`
|
||||
- **Read config**: `field get <tableId> <fieldIdOrName>` → `options.choices[].visibilityFilter` (rendered human-readable in default mode; raw JSON with `--json`)
|
||||
- **Survey a whole table**: `field list <tableId> --with-options` shows a constraint count per field plus per-choice conditions
|
||||
- **Write config**: `field update --options` (convert API). Backend rejects self-reference (`Self-reference in visibilityFilter is not allowed`) and dependency cycles (`Dependency cycle detected…`) with 400 — enforced on both V1 and V2 paths since fix `26ed1f732`
|
||||
- **Evaluate for a record**: `inkreach-teable field visible-choices <tableId> <recordId> <fieldId>` → choices currently selectable
|
||||
- **Evaluate for a record**: `field visible-choices <tableId> <recordId> <fieldIdOrName>` → choices currently selectable
|
||||
- **Write-time enforcement**: writing a record value that fails its visibility condition → 400 `Option "X" does not meet the current row conditions and cannot be selected.`
|
||||
|
||||
---
|
||||
@@ -346,6 +330,42 @@ Deleting a view does NOT delete the table or its data — only the view configur
|
||||
|
||||
---
|
||||
|
||||
## View Presets
|
||||
|
||||
Presets are **view-scoped, named bundles of view configuration** (filter/sort/group, optionally options/columnMeta), stored server-side and shared with all collaborators — each view owns its own preset list. Users apply them from the toolbar preset dropdown (labeled by current state: 公共 / 个人 / active preset name; the old "个人" toggle button was merged into this dropdown). Applying seeds the user's personal-view overlay (persisted server-side per user per view), so operators can work in different presets simultaneously without touching the collaborative view. Managing presets requires table-level view-update permission.
|
||||
|
||||
### Typical use
|
||||
|
||||
One-click screening configs for operators, e.g. "pending orders" vs "in-progress orders" on an order table, without granting view-edit permission.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# preset screening pending orders (bound to one view)
|
||||
inkreach-teable preset create --table-id tblXxx --view-id viwXxx --name "待处理订单" \
|
||||
--filter '{"conjunction":"and","filterSet":[{"fieldId":"fldStatus","operator":"is","value":"待处理"}]}'
|
||||
|
||||
# preset for in-progress orders, newest first
|
||||
inkreach-teable preset create --table-id tblXxx --view-id viwXxx --name "正在处理的订单" \
|
||||
--filter '{"conjunction":"and","filterSet":[{"fieldId":"fldStatus","operator":"is","value":"处理中"}]}' \
|
||||
--sort '{"sortObjs":[{"fieldId":"fldCreated","order":"desc"}]}'
|
||||
|
||||
inkreach-teable preset list tblXxx --view-id viwXxx
|
||||
inkreach-teable preset update <presetId> --table-id tblXxx --name "新名字" # or replace --filter/--sort/...
|
||||
inkreach-teable preset delete <presetId> --table-id tblXxx
|
||||
```
|
||||
|
||||
### Semantics
|
||||
|
||||
- Config keys you do NOT pass stay **unset**: on apply, the preset keeps the view's current value for those keys (a filter-only preset does not touch the sort).
|
||||
- Keys you DO pass **replace** that aspect on apply.
|
||||
- `--column-meta` snapshots field visibility/order — avoid unless needed: fields added later stay hidden while that preset is active (stale snapshot).
|
||||
- Deleting a preset does not touch views or records; users applying it fall back to the public config on next switch.
|
||||
- REST equivalents: `GET/POST /api/table/:tableId/view-preset` (list accepts `?viewId=`; create requires `viewId`), `PUT/DELETE /api/table/:tableId/view-preset/:presetId` (writes need view-update permission).
|
||||
- Per-user personal config: `GET/PUT/DELETE /api/table/:tableId/view-preset/personal?viewId=` (view-read permission; PUT body `{config, enabled}`) — this is what the UI "个人配置" entry persists.
|
||||
|
||||
---
|
||||
|
||||
## Value Formats Quick Reference
|
||||
|
||||
| Field Type | Value format | Notes |
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Install/upgrade the inkreach-teable CLI from the local monorepo source.
|
||||
# Run from this skill's directory. Builds the CLI package and links it globally.
|
||||
# Install/upgrade the inkreach-teable CLI + agent skill from this standalone repo.
|
||||
# Run from this skill's directory (or anywhere; paths resolve from this file).
|
||||
# What it does:
|
||||
# 1. Builds the CLI (this repo's root IS the package).
|
||||
# 2. Links the `inkreach-teable` command globally.
|
||||
# 3. Installs the skill to ~/.zcode/skills/inkreach-teable (user scope —
|
||||
# available in every ZCode workspace, shadows same-named workspace skills).
|
||||
# Re-run this script after pulling changes to refresh both the command and the skill.
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Resolve the project root: skill lives at <root>/.agents/skills/inkreach-teable/
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
CLI_DIR="$PROJECT_ROOT/packages/inkreach-cli"
|
||||
# Resolve the repo root: skill lives at <root>/.agents/skills/inkreach-teable/
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
SKILL_DIR="$REPO_ROOT/.agents/skills/inkreach-teable"
|
||||
|
||||
echo "=== inkreach-teable CLI installation ==="
|
||||
|
||||
@@ -22,16 +28,16 @@ if [ "$NODE_VERSION" -lt 18 ]; then
|
||||
fi
|
||||
echo "✓ Node.js $(node -v)"
|
||||
|
||||
# Check CLI source exists
|
||||
if [ ! -d "$CLI_DIR" ]; then
|
||||
echo "ERROR: CLI source not found at $CLI_DIR"
|
||||
echo " Ensure the inkreach-teable repo is checked out at $PROJECT_ROOT"
|
||||
# Check repo layout
|
||||
if [ ! -f "$REPO_ROOT/package.json" ] || [ ! -d "$REPO_ROOT/src" ]; then
|
||||
echo "ERROR: CLI source not found at $REPO_ROOT"
|
||||
echo " Ensure the inkreach-teable-cli repo is checked out at $REPO_ROOT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build
|
||||
echo "Building CLI from $CLI_DIR ..."
|
||||
cd "$CLI_DIR"
|
||||
echo "Building CLI from $REPO_ROOT ..."
|
||||
cd "$REPO_ROOT"
|
||||
if command -v pnpm &> /dev/null; then
|
||||
pnpm install --prefer-offline 2>/dev/null || true
|
||||
pnpm build
|
||||
@@ -49,14 +55,22 @@ elif command -v npm &> /dev/null && npm link 2>/dev/null; then
|
||||
LINKED=1
|
||||
fi
|
||||
# Ensure a usable wrapper exists on PATH even if global bin isn't configured.
|
||||
BIN_DIR="${PNPM_GLOBAL_BIN:-$(npm bin -g 2>/dev/null):-${HOME}/.local/bin}"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
cat > "$HOME/.local/bin/inkreach-teable" <<WRAPPER
|
||||
#!/bin/bash
|
||||
exec node "$CLI_DIR/dist/bin.js" "\$@"
|
||||
exec node "$REPO_ROOT/dist/bin.js" "\$@"
|
||||
WRAPPER
|
||||
chmod +x "$HOME/.local/bin/inkreach-teable"
|
||||
|
||||
# Install the skill at user scope so it works in ANY workspace, including
|
||||
# checkouts of the inkreach-teable application repo (which no longer bundles it).
|
||||
if [ -d "$SKILL_DIR" ]; then
|
||||
mkdir -p "$HOME/.zcode/skills"
|
||||
rm -rf "$HOME/.zcode/skills/inkreach-teable"
|
||||
cp -r "$SKILL_DIR" "$HOME/.zcode/skills/inkreach-teable"
|
||||
echo "✓ skill installed to ~/.zcode/skills/inkreach-teable (user scope)"
|
||||
fi
|
||||
|
||||
# Verify
|
||||
echo ""
|
||||
echo "=== Verification ==="
|
||||
@@ -69,5 +83,5 @@ if command -v inkreach-teable &> /dev/null; then
|
||||
echo " inkreach-teable auth token create --name my-cli"
|
||||
else
|
||||
echo "⚠ 'inkreach-teable' not on PATH. Add pnpm/npm global bin to your PATH, or run directly:"
|
||||
echo " node $CLI_DIR/dist/bin.js <command>"
|
||||
echo " node $REPO_ROOT/dist/bin.js <command>"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,50 @@
|
||||
# inkreach-teable-cli
|
||||
|
||||
操作 [inkreach-teable](ssh://git@gitea.inkreach.cc:2222/inkreachhub/inkreach-teable.git)
|
||||
实例(Teable fork + 自建自动化引擎)的独立工具项目,包含两部分:
|
||||
|
||||
| 内容 | 位置 | 说明 |
|
||||
|---|---|---|
|
||||
| **CLI**(命令 `inkreach-teable`) | 仓库根(`src/`,包即仓库) | 表、字段、记录、视图、自动化(8 触发器 + 7 动作)、密钥、执行历史、插件、订阅等全部 REST API 封装 |
|
||||
| **Agent Skill** | `.agents/skills/inkreach-teable/` | ZCode/Claude 技能:模块地图、路由规则、API 参考、实例运维指南 |
|
||||
|
||||
设计文档见 [`docs/cli-skill-design.md`](docs/cli-skill-design.md)。
|
||||
|
||||
> ⚠️ 官方 `teable` CLI 与本实例的自动化契约不兼容(`conditions` 而非 `filter`、
|
||||
> `fieldIds` 而非 `watchFieldIds` 等),务必使用 `inkreach-teable`。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
git clone ssh://git@gitea.inkreach.cc:2222/inkreachhub/inkreach-teable-cli.git
|
||||
cd inkreach-teable-cli
|
||||
bash .agents/skills/inkreach-teable/scripts/install.sh
|
||||
```
|
||||
|
||||
安装脚本会:构建 CLI → 全局链接 `inkreach-teable` 命令 → 把 skill 安装到
|
||||
`~/.zcode/skills/inkreach-teable`(用户级,任意工作区可用)。更新后重跑一次即可。
|
||||
|
||||
## 配置
|
||||
|
||||
```bash
|
||||
inkreach-teable auth login --endpoint http://localhost:3003 --token <PAT> --base-id bseXXX
|
||||
inkreach-teable auth status
|
||||
```
|
||||
|
||||
PAT 从 Web UI(Settings → Personal Access Tokens)或 `auth token create` 获取。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build # tsc → dist/
|
||||
pnpm test # vitest 单测(field-normalize / field-display / resources)
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## 来源
|
||||
|
||||
2026-09-23 从 inkreach-teable monorepo 拆分独立(原路径 `packages/inkreach-cli/` +
|
||||
`.agents/skills/inkreach-teable/`),git 历史完整保留。应用本体的开发/部署文档见
|
||||
skill 内 [development.md](.agents/skills/inkreach-teable/guides/development.md)(描述的是
|
||||
inkreach-teable 应用仓库,不是本仓库)。
|
||||
Vendored
-44
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
/**
|
||||
* inkreach-teable — CLI for operating inkreach-teable.
|
||||
*
|
||||
* Entry point. Sets up the global commander program and mounts command groups.
|
||||
*/
|
||||
const commander_1 = require("commander");
|
||||
const package_json_1 = require("../package.json");
|
||||
const plugin_1 = require("./commands/plugin");
|
||||
const auth_1 = require("./commands/auth");
|
||||
const workflow_1 = require("./commands/workflow");
|
||||
const resources_1 = require("./commands/resources");
|
||||
const extras_1 = require("./commands/extras");
|
||||
const program = new commander_1.Command();
|
||||
program
|
||||
.name('inkreach-teable')
|
||||
.description('Operate inkreach-teable: tables, fields, records, views, and the self-built automation engine (8 triggers + 6 actions).\n' +
|
||||
'Use --json for machine-readable output (agent parsing).')
|
||||
.version(`inkreach-teable ${package_json_1.version}`, '-v, --version');
|
||||
// Global options (highest precedence over config file + env).
|
||||
program
|
||||
.option('--endpoint <url>', 'Backend URL (default http://localhost:3000)')
|
||||
.option('--token <token>', 'Personal Access Token')
|
||||
.option('--base-id <id>', 'Default base id')
|
||||
.option('--json', 'Output raw JSON for agent parsing')
|
||||
.option('--debug', 'Print requests/responses to stderr (verbose)');
|
||||
// Command groups
|
||||
program.addCommand((0, auth_1.authCommand)());
|
||||
program.addCommand((0, plugin_1.pluginCommand)());
|
||||
for (const c of (0, resources_1.baseResourceCommands)())
|
||||
program.addCommand(c);
|
||||
program.addCommand((0, workflow_1.workflowCommand)());
|
||||
program.addCommand((0, workflow_1.triggerCommand)());
|
||||
program.addCommand((0, workflow_1.actionCommand)());
|
||||
program.addCommand((0, extras_1.secretCommand)());
|
||||
program.addCommand((0, extras_1.runsCommand)());
|
||||
program.addCommand((0, extras_1.fireCommand)());
|
||||
program.addCommand((0, extras_1.sqlCommand)());
|
||||
program.parseAsync(process.argv).catch((err) => {
|
||||
process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
Vendored
-139
@@ -1,139 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.authCommand = void 0;
|
||||
/**
|
||||
* `inkreach-teable auth` — authentication & config commands.
|
||||
*/
|
||||
const commander_1 = require("commander");
|
||||
const ctx_1 = require("../lib/ctx");
|
||||
const config_1 = require("../lib/config");
|
||||
const output_1 = require("../lib/output");
|
||||
void ctx_1.requireBaseId;
|
||||
function authCommand() {
|
||||
const cmd = new commander_1.Command('auth');
|
||||
cmd.description('Manage authentication and CLI config');
|
||||
cmd
|
||||
.command('status')
|
||||
.description('Verify the configured token by calling GET /api/user/me')
|
||||
.action((0, ctx_1.run)(async (ctx) => {
|
||||
const me = (await ctx.client.getMe());
|
||||
const cfg = (0, config_1.readConfigFile)();
|
||||
if (ctx.json) {
|
||||
(0, output_1.printJson)({ config: cfg, user: me });
|
||||
}
|
||||
else {
|
||||
(0, output_1.printKv)([
|
||||
['config path', (0, config_1.configPath)()],
|
||||
['endpoint', cfg.endpoint || config_1.DEFAULT_ENDPOINT],
|
||||
['token set', cfg.token ? `yes (${cfg.token.slice(0, 14)}…)` : 'no'],
|
||||
['base id', cfg.baseId || '(none)'],
|
||||
['', ''],
|
||||
['user id', me?.id || 'unknown'],
|
||||
['name', me?.name || 'unknown'],
|
||||
['email', me?.email || 'unknown'],
|
||||
]);
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('login')
|
||||
.description('Configure endpoint, token, and default base id')
|
||||
.option('--endpoint <url>', 'Backend URL (default http://localhost:3000)')
|
||||
.option('--token <pat>', 'Personal Access Token (format: teable_xxx_yyy)')
|
||||
.option('--base-id <id>', 'Default base id for subsequent commands')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
// Merge global flags (--token/--endpoint/--base-id on the program) with
|
||||
// this subcommand's own --endpoint/--token/--base-id options.
|
||||
const g = ctx.program.opts();
|
||||
const patch = {};
|
||||
const endpoint = opts.endpoint || g.endpoint;
|
||||
const tokenVal = opts.token || g.token;
|
||||
const baseId = opts.baseId || g.baseId;
|
||||
if (endpoint)
|
||||
patch.endpoint = endpoint;
|
||||
if (tokenVal)
|
||||
patch.token = tokenVal;
|
||||
if (baseId)
|
||||
patch.baseId = baseId;
|
||||
(0, config_1.writeConfigFile)(patch);
|
||||
const cfg = (0, config_1.readConfigFile)();
|
||||
if (ctx.json) {
|
||||
(0, output_1.printJson)({ ok: true, config: cfg });
|
||||
}
|
||||
else {
|
||||
process.stdout.write('✓ Configuration saved:\n');
|
||||
(0, output_1.printKv)([
|
||||
['endpoint', cfg.endpoint || config_1.DEFAULT_ENDPOINT],
|
||||
['token', cfg.token ? `set (${cfg.token.slice(0, 14)}…)` : '(not set)'],
|
||||
['base id', cfg.baseId || '(none)'],
|
||||
['path', (0, config_1.configPath)()],
|
||||
]);
|
||||
}
|
||||
}));
|
||||
const token = cmd.command('token').description('Manage Personal Access Tokens (PAT)');
|
||||
token
|
||||
.command('list')
|
||||
.description('List your access tokens')
|
||||
.action((0, ctx_1.run)(async (ctx) => {
|
||||
const list = (await ctx.client.listAccessTokens());
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(list);
|
||||
else
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'scopes', header: 'SCOPES' },
|
||||
{ key: 'expiredTime', header: 'EXPIRES' },
|
||||
], list.map((t) => ({ ...t, scopes: (t.scopes || []).join(',') })));
|
||||
}));
|
||||
token
|
||||
.command('create')
|
||||
.description('Create a PAT. The full token is returned ONLY ONCE.')
|
||||
.requiredOption('--name <name>', 'Token name')
|
||||
.option('--scopes <csv>', 'Comma-separated scopes', 'automation|read,automation|create,automation|update,automation|delete,table|read,record|read,record|create,record|update')
|
||||
.option('--base-ids <csv>', 'Restrict to base ids (comma-separated). Omit for full access')
|
||||
.option('--full-access', 'Grant full access')
|
||||
.option('--expires <date>', 'Expiry date YYYY-MM-DD (default 2099-12-31)', '2099-12-31')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
// commander stores --kebab-case flags camelCased (--base-ids ->
|
||||
// opts.baseIds, --full-access -> opts.fullAccess); accept both.
|
||||
const opts = o;
|
||||
const scopes = opts.scopes.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const rawBaseIds = opts['base-ids'] ?? opts.baseIds;
|
||||
const baseIds = rawBaseIds
|
||||
? rawBaseIds.split(',').map((s) => s.trim())
|
||||
: undefined;
|
||||
const fullAccess = Boolean(opts['full-access'] ?? opts.fullAccess);
|
||||
const created = (await ctx.client.createAccessToken({
|
||||
name: opts.name,
|
||||
scopes,
|
||||
baseIds,
|
||||
hasFullAccess: fullAccess,
|
||||
expiredTime: opts.expires,
|
||||
}));
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(created);
|
||||
else {
|
||||
(0, output_1.printKv)([
|
||||
['id', created.id],
|
||||
['token', created.token],
|
||||
['', ''],
|
||||
['note', 'Save this token now — it cannot be retrieved again.'],
|
||||
['use', 'inkreach-teable auth login --token <above>'],
|
||||
]);
|
||||
}
|
||||
}));
|
||||
token
|
||||
.command('delete <id>')
|
||||
.description('Revoke an access token')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const id = args[0];
|
||||
await ctx.client.deleteAccessToken(id);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)({ ok: true, id });
|
||||
else
|
||||
process.stdout.write(`✓ Token ${id} revoked\n`);
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
exports.authCommand = authCommand;
|
||||
Vendored
-288
@@ -1,288 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.sqlCommand = exports.fireCommand = exports.runsCommand = exports.secretCommand = void 0;
|
||||
/**
|
||||
* `inkreach-teable secret` — manage workflow automation secrets.
|
||||
* `inkreach-teable runs` — execution history.
|
||||
* `inkreach-teable fire` — manual trigger testing (button-click / webhook).
|
||||
* `inkreach-teable sql` — read-only SQL queries.
|
||||
*/
|
||||
const commander_1 = require("commander");
|
||||
const ctx_1 = require("../lib/ctx");
|
||||
const output_1 = require("../lib/output");
|
||||
// ===================== secret =====================
|
||||
function secretCommand() {
|
||||
const cmd = new commander_1.Command('secret');
|
||||
cmd.description('Manage automation script secrets');
|
||||
cmd
|
||||
.command('list <workflowId>')
|
||||
.description('List secrets for a workflow. Values are never returned.')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const workflowId = args[0];
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const list = (await ctx.client.listSecrets(baseId, workflowId));
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(list);
|
||||
else {
|
||||
if (!list.length) {
|
||||
process.stdout.write('No secrets found.\n');
|
||||
return;
|
||||
}
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'description', header: 'DESCRIPTION' },
|
||||
], list.map((s) => ({
|
||||
id: s.id,
|
||||
name: (0, output_1.truncate)(s.name, 30),
|
||||
description: (0, output_1.truncate)(s.description, 50),
|
||||
})));
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('set <workflowId>')
|
||||
.description('Create a new secret')
|
||||
.requiredOption('--name <name>', 'Secret name')
|
||||
.requiredOption('--value <value>', 'Secret value')
|
||||
.option('--description <desc>', 'Secret description')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const workflowId = args[0];
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const created = await ctx.client.createSecret(baseId, workflowId, {
|
||||
name: opts.name,
|
||||
value: opts.value,
|
||||
description: opts.description,
|
||||
});
|
||||
const s = created;
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(created);
|
||||
else {
|
||||
(0, output_1.printKv)([
|
||||
['id', s.id],
|
||||
['name', s.name],
|
||||
['', ''],
|
||||
['note', 'Secret value is never returned. Update it if needed.'],
|
||||
]);
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('update <workflowId> <secretId>')
|
||||
.description('Update a secret')
|
||||
.option('--name <name>', 'New name')
|
||||
.option('--value <value>', 'New value')
|
||||
.option('--description <desc>', 'New description')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const [workflowId, secretId] = args;
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
await ctx.client.updateSecret(baseId, workflowId, secretId, {
|
||||
name: opts.name,
|
||||
value: opts.value,
|
||||
description: opts.description,
|
||||
});
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)({ ok: true, id: secretId });
|
||||
else
|
||||
process.stdout.write(`✓ Secret ${secretId} updated\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('delete <workflowId> <secretId>')
|
||||
.description('Delete a secret')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const [workflowId, secretId] = args;
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
await ctx.client.deleteSecret(baseId, workflowId, secretId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)({ ok: true, id: secretId });
|
||||
else
|
||||
process.stdout.write(`✓ Secret ${secretId} deleted\n`);
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
exports.secretCommand = secretCommand;
|
||||
// ===================== runs =====================
|
||||
function runsCommand() {
|
||||
const cmd = new commander_1.Command('runs');
|
||||
cmd.description('Execution history for automations');
|
||||
cmd
|
||||
.command('list <workflowId>')
|
||||
.description('List execution history')
|
||||
.option('--status <status>', 'Filter by status: failed, succeeded, or running')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const workflowId = args[0];
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
let list = (await ctx.client.listExecutions(baseId, workflowId));
|
||||
if (opts.status) {
|
||||
list = list.filter((e) => e.status === opts.status);
|
||||
}
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(list);
|
||||
else {
|
||||
if (!list.length) {
|
||||
process.stdout.write('No executions found.\n');
|
||||
return;
|
||||
}
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'status', header: 'STATUS' },
|
||||
{ key: 'triggerType', header: 'TRIGGER' },
|
||||
{ key: 'startedAt', header: 'STARTED' },
|
||||
{ key: 'finishedAt', header: 'FINISHED' },
|
||||
], list.map((e) => ({
|
||||
id: e.id,
|
||||
status: e.status,
|
||||
triggerType: e.triggerType || '',
|
||||
startedAt: (0, output_1.truncate)(e.startedAt, 25),
|
||||
finishedAt: (0, output_1.truncate)(e.finishedAt, 25),
|
||||
})));
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('get <workflowId> <executionId>')
|
||||
.description('Get execution detail with node results')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const [workflowId, executionId] = args;
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const exec = await ctx.client.getExecution(baseId, workflowId, executionId);
|
||||
const ex = exec;
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(exec);
|
||||
else {
|
||||
(0, output_1.printKv)([
|
||||
['id', ex.id],
|
||||
['status', ex.status],
|
||||
['trigger', ex.triggerType || ''],
|
||||
['started', ex.startedAt || ''],
|
||||
['finished', ex.finishedAt || ''],
|
||||
]);
|
||||
if (ex.nodeResults?.length) {
|
||||
process.stdout.write('\nNODE RESULTS:\n');
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'nodeId', header: 'NODE' },
|
||||
{ key: 'status', header: 'STATUS' },
|
||||
{ key: 'error', header: 'ERROR' },
|
||||
], ex.nodeResults.map((r) => ({
|
||||
nodeId: r.nodeId,
|
||||
status: r.status,
|
||||
error: (0, output_1.truncate)(r.error, 50),
|
||||
})));
|
||||
}
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('rerun <workflowId> <executionId>')
|
||||
.description('Rerun a failed execution')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const [workflowId, executionId] = args;
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const res = (await ctx.client.rerunExecution(baseId, workflowId, executionId));
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(res);
|
||||
else {
|
||||
const pairs = [
|
||||
['executionId', executionId],
|
||||
['status', 'rerun requested'],
|
||||
];
|
||||
if (res.id)
|
||||
pairs.push(['newExecutionId', res.id]);
|
||||
(0, output_1.printKv)(pairs);
|
||||
}
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
exports.runsCommand = runsCommand;
|
||||
// ===================== fire =====================
|
||||
function fireCommand() {
|
||||
const cmd = new commander_1.Command('fire');
|
||||
cmd.description('Manually fire triggers for testing');
|
||||
cmd
|
||||
.command('button-click <tableId> <recordId> <fieldId>')
|
||||
.description('Simulate a button field click')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const [tableId, recordId, fieldId] = args;
|
||||
const res = await ctx.client.fireButtonClick(tableId, recordId, fieldId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(res);
|
||||
else {
|
||||
(0, output_1.printKv)([
|
||||
['runId', res.runId],
|
||||
['tableId', res.tableId],
|
||||
['fieldId', res.fieldId],
|
||||
]);
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('webhook <baseId> <workflowId>')
|
||||
.description('Simulate a webhook call')
|
||||
.option('--body <json>', 'JSON body to send')
|
||||
.option('--method <method>', 'HTTP method (informational only)', 'POST')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const [baseId, workflowId] = args;
|
||||
const opts = o;
|
||||
const body = opts.body ? JSON.parse(opts.body) : undefined;
|
||||
const res = await ctx.client.fireWebhook(baseId, workflowId, body);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(res);
|
||||
else {
|
||||
process.stdout.write(`✓ Webhook fired for workflow ${workflowId}\n`);
|
||||
if (typeof res === 'object' && res !== null) {
|
||||
(0, output_1.printJson)(res);
|
||||
}
|
||||
}
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
exports.fireCommand = fireCommand;
|
||||
// ===================== sql =====================
|
||||
function sqlCommand() {
|
||||
const cmd = new commander_1.Command('sql');
|
||||
cmd.description('Execute a read-only SQL query (SELECT only)');
|
||||
cmd.option('--base-id <id>', 'Base id (overrides default)');
|
||||
cmd.argument('<sql>', 'SQL SELECT statement');
|
||||
cmd.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const sql = args[0];
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const rows = (await ctx.client.sql(baseId, sql));
|
||||
if (ctx.json) {
|
||||
(0, output_1.printJson)(rows);
|
||||
}
|
||||
else {
|
||||
if (!rows.length) {
|
||||
process.stdout.write('No rows returned.\n');
|
||||
return;
|
||||
}
|
||||
const first = rows[0];
|
||||
if (typeof first === 'object' && first !== null && !Array.isArray(first)) {
|
||||
const keys = Object.keys(first);
|
||||
(0, output_1.printTable)(keys.map((k) => ({ key: k, header: k })), rows.map((r) => {
|
||||
const obj = r;
|
||||
const row = {};
|
||||
for (const k of keys) {
|
||||
row[k] = (0, output_1.truncate)(obj[k], 40);
|
||||
}
|
||||
return row;
|
||||
}));
|
||||
process.stdout.write(`\n${rows.length} row(s).\n`);
|
||||
}
|
||||
else {
|
||||
rows.forEach((r) => process.stdout.write(JSON.stringify(r) + '\n'));
|
||||
}
|
||||
}
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
exports.sqlCommand = sqlCommand;
|
||||
Vendored
-273
@@ -1,273 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.pluginCommand = void 0;
|
||||
/**
|
||||
* `inkreach-teable plugin` — plugin management commands.
|
||||
*/
|
||||
const commander_1 = require("commander");
|
||||
const ctx_1 = require("../lib/ctx");
|
||||
const output_1 = require("../lib/output");
|
||||
const request_1 = require("../lib/request");
|
||||
void ctx_1.requireBaseId;
|
||||
// ---- developer whitelist subcommands (Feature 4) ----
|
||||
function addDevCommands(cmd) {
|
||||
const dev = cmd.command('dev').description('Manage plugin developer whitelist (admin)');
|
||||
dev
|
||||
.command('list')
|
||||
.description('List all whitelisted developers')
|
||||
.action((0, ctx_1.run)(async (ctx) => {
|
||||
const list = (await ctx.client.listDevelopers());
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(list);
|
||||
else if (!list.length)
|
||||
process.stdout.write('(no developers)\n');
|
||||
else
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'userId', header: 'USER_ID' },
|
||||
{ key: 'email', header: 'EMAIL' },
|
||||
{ key: 'createdBy', header: 'ADDED_BY' },
|
||||
], list);
|
||||
}));
|
||||
dev
|
||||
.command('allow')
|
||||
.description('Add a user to the developer whitelist (userId or email)')
|
||||
.argument('<userIdOrEmail>', 'User ID or email')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const userIdOrEmail = args[0];
|
||||
await ctx.client.addDeveloper(userIdOrEmail);
|
||||
process.stdout.write(`✓ Developer added: ${userIdOrEmail}\n`);
|
||||
}));
|
||||
dev
|
||||
.command('revoke')
|
||||
.description('Remove a user from the developer whitelist (userId or email)')
|
||||
.argument('<userIdOrEmail>', 'User ID or email')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const userIdOrEmail = args[0];
|
||||
await ctx.client.removeDeveloper(userIdOrEmail);
|
||||
process.stdout.write(`✓ Developer revoked: ${userIdOrEmail}\n`);
|
||||
}));
|
||||
}
|
||||
// ---- install / uninstall subcommands (Feature 1) ----
|
||||
function addInstallCommands(cmd) {
|
||||
cmd
|
||||
.command('install')
|
||||
.description('Install a plugin into the current base (creates a node)')
|
||||
.requiredOption('--plugin <id>', 'Plugin ID')
|
||||
.requiredOption('--name <name>', 'Display name for this node')
|
||||
.requiredOption('--position <pos>', 'Mount position: base | panel | view | dashboard | contextMenu', 'base')
|
||||
.option('--storage <json>', 'Initial storage JSON passed to the plugin')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
const body = {
|
||||
pluginId: opts.plugin,
|
||||
name: opts.name,
|
||||
position: opts.position,
|
||||
};
|
||||
if (opts.storage)
|
||||
body.storage = opts.storage;
|
||||
const result = (await ctx.client.createInstall(ctx.baseId, body));
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(result);
|
||||
else
|
||||
process.stdout.write(`✓ Plugin installed: ${result.id} (${opts.name} → ${opts.position})\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('uninstall')
|
||||
.description('Remove a plugin install by its install ID')
|
||||
.requiredOption('--install <id>', 'Install ID')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
await ctx.client.deleteInstall(ctx.baseId, opts.install);
|
||||
process.stdout.write(`✓ Plugin install removed: ${opts.install}\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('installs')
|
||||
.description('List plugin installs for the current base')
|
||||
.action((0, ctx_1.run)(async (ctx) => {
|
||||
const list = (await ctx.client.listInstalls(ctx.baseId));
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(list);
|
||||
else if (!list.length)
|
||||
process.stdout.write('(no installs)\n');
|
||||
else
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'INSTALL_ID' },
|
||||
{ key: 'pluginId', header: 'PLUGIN' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'position', header: 'POSITION' },
|
||||
], list);
|
||||
}));
|
||||
}
|
||||
function pluginCommand() {
|
||||
const cmd = new commander_1.Command('plugin');
|
||||
cmd.description('Manage plugins (list, register, config, create, install, dev)');
|
||||
cmd
|
||||
.command('list')
|
||||
.description('List registered plugins')
|
||||
.action((0, ctx_1.run)(async (ctx) => {
|
||||
try {
|
||||
const list = (await ctx.client.getPlugins());
|
||||
if (ctx.json) {
|
||||
(0, output_1.printJson)(list);
|
||||
}
|
||||
else {
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'status', header: 'STATUS' },
|
||||
{ key: 'runtimeType', header: 'RUNTIME' },
|
||||
], list.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
status: p.status,
|
||||
runtimeType: p.runtimeType ?? 'url',
|
||||
})));
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof request_1.ApiError && e.status === 401) {
|
||||
process.stderr.write('Unauthorized — log in via "auth login" or provide --token\n');
|
||||
process.exit(1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('register')
|
||||
.description('Register a plugin by URL')
|
||||
.requiredOption('--url <url>', 'Plugin URL')
|
||||
.requiredOption('--name <name>', 'Plugin name')
|
||||
.option('--description <desc>', 'Plugin description')
|
||||
.option('--logo <url>', 'Logo URL')
|
||||
.option('--positions <json>', 'Positions array, e.g. \'["panel"]\'', '["panel"]')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
const positions = JSON.parse(opts.positions);
|
||||
const created = await ctx.client.registerPlugin({
|
||||
name: opts.name,
|
||||
url: opts.url,
|
||||
description: opts.description,
|
||||
logo: opts.logo ?? '',
|
||||
positions,
|
||||
status: 'published',
|
||||
});
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(created);
|
||||
else
|
||||
process.stdout.write(`✓ Plugin registered: ${created.id}\n`);
|
||||
}));
|
||||
const config = cmd.command('config').description('Manage per-table plugin enablement');
|
||||
config
|
||||
.command('list')
|
||||
.description('List plugin configs for the base')
|
||||
.action((0, ctx_1.run)(async (ctx) => {
|
||||
const list = await ctx.client.listPluginConfigs(ctx.baseId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(list);
|
||||
else {
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'pluginId', header: 'PLUGIN' },
|
||||
{ key: 'tableId', header: 'TABLE' },
|
||||
{ key: 'enabled', header: 'ON' },
|
||||
{ key: 'runtimeForm', header: 'FORM' },
|
||||
{ key: 'writeEnabled', header: 'WRITE' },
|
||||
], list.map((r) => ({
|
||||
pluginId: r.pluginId,
|
||||
tableId: r.tableId || '-',
|
||||
enabled: r.enabled,
|
||||
runtimeForm: r.runtimeForm,
|
||||
writeEnabled: r.writeEnabled,
|
||||
})));
|
||||
}
|
||||
}));
|
||||
config
|
||||
.command('enable')
|
||||
.description('Enable a plugin for a table (or base-wide if no --table)')
|
||||
.requiredOption('--plugin <id>', 'Plugin ID')
|
||||
.option('--table <id>', 'Table ID (omit for base-wide)')
|
||||
.option('--write', 'Allow write-back')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
const result = await ctx.client.upsertPluginConfig(ctx.baseId, opts.plugin, {
|
||||
enabled: true,
|
||||
runtimeForm: 'panel',
|
||||
writeEnabled: !!opts.write,
|
||||
tableIds: opts.table ? [opts.table] : [],
|
||||
});
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(result);
|
||||
else
|
||||
process.stdout.write(`✓ Plugin ${opts.plugin} enabled${opts.table ? ` for table ${opts.table}` : ' base-wide'}\n`);
|
||||
}));
|
||||
config
|
||||
.command('disable')
|
||||
.description('Disable a plugin for a table (or base-wide if no --table)')
|
||||
.requiredOption('--plugin <id>', 'Plugin ID')
|
||||
.option('--table <id>', 'Table ID')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
await ctx.client.upsertPluginConfig(ctx.baseId, opts.plugin, {
|
||||
enabled: false,
|
||||
runtimeForm: 'panel',
|
||||
writeEnabled: false,
|
||||
tableIds: opts.table ? [opts.table] : [],
|
||||
});
|
||||
process.stdout.write(`✓ Plugin ${opts.plugin} disabled${opts.table ? ` for table ${opts.table}` : ' base-wide'}\n`);
|
||||
}));
|
||||
// ---- CSP apiOrigins management ----
|
||||
const csp = cmd.command('csp').description('Manage plugin CSP apiOrigins');
|
||||
csp
|
||||
.command('list')
|
||||
.description('Show the union of all plugins\' apiOrigins (effective CSP connect-src extras)')
|
||||
.action((0, ctx_1.run)(async (ctx) => {
|
||||
const { origins } = await ctx.client.getCspOrigins();
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(origins);
|
||||
else if (!origins.length)
|
||||
process.stdout.write('(no apiOrigins declared by any plugin)\n');
|
||||
else
|
||||
origins.forEach((o) => process.stdout.write(`${o}\n`));
|
||||
}));
|
||||
csp
|
||||
.command('set <pluginId>')
|
||||
.description('Set the external API origins a plugin may fetch (bundle plugins included)')
|
||||
.requiredOption('--origins <json>', 'JSON array of origins, e.g. \'["http://192.168.124.92:8001"]\'')
|
||||
.option('--clear', 'Remove all origins (pass "[]" works too)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const opts = o;
|
||||
const pluginId = args[0];
|
||||
let origins;
|
||||
try {
|
||||
const parsed = JSON.parse(opts.origins);
|
||||
if (!Array.isArray(parsed) || parsed.some((x) => typeof x !== 'string')) {
|
||||
throw new Error('not a string array');
|
||||
}
|
||||
origins = parsed;
|
||||
}
|
||||
catch {
|
||||
process.stderr.write(`✗ --origins must be a JSON string array, got: ${opts.origins}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.clear)
|
||||
origins = [];
|
||||
// updatePlugin requires name + positions (full-vo update): fetch current values.
|
||||
const cur = await ctx.client.getPlugin(pluginId);
|
||||
await ctx.client.updatePlugin(pluginId, {
|
||||
name: cur.name,
|
||||
positions: cur.positions,
|
||||
description: cur.description,
|
||||
detailDesc: cur.detailDesc,
|
||||
url: cur.url,
|
||||
logo: cur.logo,
|
||||
helpUrl: cur.helpUrl,
|
||||
apiOrigins: origins,
|
||||
});
|
||||
process.stdout.write(`✓ apiOrigins for ${pluginId} set to: ${JSON.stringify(origins)}\n`);
|
||||
}));
|
||||
// Wire feature subcommands
|
||||
addDevCommands(cmd);
|
||||
addInstallCommands(cmd);
|
||||
return cmd;
|
||||
}
|
||||
exports.pluginCommand = pluginCommand;
|
||||
Vendored
-538
@@ -1,538 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.baseResourceCommands = void 0;
|
||||
/**
|
||||
* `inkreach-teable base|table|field|record|view` — resource CRUD commands.
|
||||
*
|
||||
* These endpoints are compatible with official teable open-api.
|
||||
*/
|
||||
const commander_1 = require("commander");
|
||||
const ctx_1 = require("../lib/ctx");
|
||||
const output_1 = require("../lib/output");
|
||||
const field_normalize_1 = require("../lib/field-normalize");
|
||||
function baseResourceCommands() {
|
||||
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()];
|
||||
}
|
||||
exports.baseResourceCommands = baseResourceCommands;
|
||||
// ===================== base =====================
|
||||
function baseCmd() {
|
||||
const cmd = new commander_1.Command('base');
|
||||
cmd.description('Manage bases (database containers)');
|
||||
cmd
|
||||
.command('list')
|
||||
.description('List all accessible bases')
|
||||
.action((0, ctx_1.run)(async (ctx) => {
|
||||
const list = (await ctx.client.listBases());
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(list);
|
||||
else {
|
||||
if (!list.length) {
|
||||
process.stdout.write('No bases found.\n');
|
||||
return;
|
||||
}
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
], list.map((b) => ({ id: (0, output_1.truncate)(b.id), name: (0, output_1.truncate)(b.name, 30) })));
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('get <baseId>')
|
||||
.description('Get base details')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const baseId = args[0];
|
||||
const base = await ctx.client.getBase(baseId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(base);
|
||||
else {
|
||||
const b = base;
|
||||
(0, output_1.printKv)(Object.entries(b).map(([k, v]) => [k, v]));
|
||||
}
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
// ===================== table =====================
|
||||
function tableCmd() {
|
||||
const cmd = new commander_1.Command('table');
|
||||
cmd.description('Manage tables within a base');
|
||||
cmd
|
||||
.command('list')
|
||||
.description('List tables in a base')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const list = (await ctx.client.listTables(baseId));
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(list);
|
||||
else {
|
||||
if (!list.length) {
|
||||
process.stdout.write('No tables found.\n');
|
||||
return;
|
||||
}
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'dbTableName', header: 'DB TABLE' },
|
||||
], list.map((t) => ({
|
||||
id: (0, output_1.truncate)(t.id),
|
||||
name: (0, output_1.truncate)(t.name, 30),
|
||||
dbTableName: (0, output_1.truncate)(t.dbTableName ?? ''),
|
||||
})));
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('get <tableId>')
|
||||
.description('Get table details')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const tableId = args[0];
|
||||
const table = await ctx.client.getTable(tableId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(table);
|
||||
else {
|
||||
const t = table;
|
||||
(0, output_1.printKv)(Object.entries(t).map(([k, v]) => [k, v]));
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('create')
|
||||
.description('Create a new table')
|
||||
.requiredOption('--name <name>', 'Table name')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.option('--fields <json>', 'Field definitions JSON array, e.g. \'[{"name":"Title","type":"singleLineText"}]\'')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const body = { name: opts.name };
|
||||
if (opts.fields) {
|
||||
body.fields = (0, field_normalize_1.normalizeFieldRoArray)(JSON.parse(opts.fields));
|
||||
// Resolve foreignTableName references against existing tables in the base.
|
||||
const fields = body.fields;
|
||||
const tables = await ctx.client.listTables(baseId).catch(() => []);
|
||||
for (const f of fields) {
|
||||
const options = f?.options;
|
||||
if (options && typeof options.foreignTableName === 'string' && !options.foreignTableId) {
|
||||
const t = (0, field_normalize_1.findTableByNameOrId)(tables || [], options.foreignTableName);
|
||||
if (t)
|
||||
options.foreignTableId = t.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
const table = await ctx.client.createTable(baseId, body);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(table);
|
||||
else {
|
||||
const t = table;
|
||||
(0, output_1.printKv)([
|
||||
['id', t.id],
|
||||
['name', t.name],
|
||||
]);
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('update <tableId>')
|
||||
.description('Update table (rename)')
|
||||
.requiredOption('--name <name>', 'New table name')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const tableId = args[0];
|
||||
const opts = o;
|
||||
const table = await ctx.client.updateTable(tableId, { name: opts.name });
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(table);
|
||||
else
|
||||
process.stdout.write(`✓ Table ${tableId} updated\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('delete <tableId>')
|
||||
.description('Delete a table')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const tableId = args[0];
|
||||
await ctx.client.deleteTable(tableId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)({ ok: true, id: tableId });
|
||||
else
|
||||
process.stdout.write(`✓ Table ${tableId} deleted\n`);
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
// ===================== field =====================
|
||||
function fieldCmd() {
|
||||
const cmd = new commander_1.Command('field');
|
||||
cmd.description('Manage fields (columns) in a table');
|
||||
cmd
|
||||
.command('list <tableId>')
|
||||
.description('List fields in a table')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const tableId = args[0];
|
||||
const list = (await ctx.client.listFields(tableId));
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(list);
|
||||
else {
|
||||
if (!list.length) {
|
||||
process.stdout.write('No fields found.\n');
|
||||
return;
|
||||
}
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'type', header: 'TYPE' },
|
||||
], list.map((f) => ({
|
||||
id: (0, output_1.truncate)(f.id),
|
||||
name: (0, output_1.truncate)(f.name, 30),
|
||||
type: f.type,
|
||||
})));
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('get <tableId> <fieldId>')
|
||||
.description('Get field details (full options incl. choice visibilityFilter)')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const [tableId, fieldId] = args;
|
||||
const field = await ctx.client.getField(tableId, fieldId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(field);
|
||||
else {
|
||||
const f = field;
|
||||
(0, output_1.printKv)(Object.entries(f).map(([k, v]) => [k, v]));
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('create')
|
||||
.description('Create a new field')
|
||||
.requiredOption('--table-id <id>', 'Table id')
|
||||
.requiredOption('--name <name>', 'Field name')
|
||||
.option('--type <type>', 'Field type — full value (singleLineText, singleSelect, link…) or alias (text, sel, num…). Omitted: inferred from --options shape / --name')
|
||||
.option('--options <json>', 'Field options JSON (type-specific; select choices accept plain strings)')
|
||||
.option('--description <d>', 'Field description')
|
||||
.option('--lookup-options <json>', 'Lookup options JSON (names like linkFieldName/lookupFieldName are resolved to IDs)')
|
||||
.option('--is-lookup', 'Mark the field as a lookup field')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
const body = { name: opts.name };
|
||||
if (opts.options)
|
||||
body.options = JSON.parse(opts.options);
|
||||
if (opts.description)
|
||||
body.description = opts.description;
|
||||
if (opts.lookupOptions)
|
||||
body.lookupOptions = JSON.parse(opts.lookupOptions);
|
||||
if (opts.isLookup)
|
||||
body.isLookup = true;
|
||||
body.type = opts.type
|
||||
? (0, field_normalize_1.resolveFieldType)(opts.type)
|
||||
: (0, field_normalize_1.inferFieldType)(opts.name, body.options);
|
||||
(0, field_normalize_1.normalizeFieldRo)(body);
|
||||
const errors = await (0, field_normalize_1.resolveFieldReferences)(body, {
|
||||
listTables: (baseId) => ctx.client.listTables(baseId),
|
||||
listFields: (tableId) => ctx.client.listFields(tableId),
|
||||
baseId: ctx.baseId,
|
||||
tableId: opts.tableId,
|
||||
});
|
||||
if (errors.length)
|
||||
throw new Error(errors.join('; '));
|
||||
const field = await ctx.client.createField(opts.tableId, body);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(field);
|
||||
else {
|
||||
const f = field;
|
||||
(0, output_1.printKv)([
|
||||
['id', f.id],
|
||||
['name', f.name],
|
||||
['type', f.type],
|
||||
]);
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('update <tableId> <fieldId>')
|
||||
.description('Update a field. --name renames (PATCH); --options rewrites options via the convert API (the update API does not accept options)')
|
||||
.option('--name <name>', 'New field name')
|
||||
.option('--options <json>', 'New field options JSON (written via convert API)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const [tableId, fieldId] = args;
|
||||
const opts = o;
|
||||
if (!opts.name && !opts.options) {
|
||||
throw new Error('Nothing to update: pass --name and/or --options');
|
||||
}
|
||||
if (opts.name && !opts.options) {
|
||||
const field = await ctx.client.updateField(tableId, fieldId, { name: opts.name });
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(field);
|
||||
else
|
||||
process.stdout.write(`✓ Field ${fieldId} renamed\n`);
|
||||
return;
|
||||
}
|
||||
// Options (choices / visibilityFilter / ...) must go through convert.
|
||||
// Fetch the current field to reuse its type; merge name if given.
|
||||
const current = (await ctx.client.getField(tableId, fieldId));
|
||||
const body = {
|
||||
type: current.type,
|
||||
options: JSON.parse(opts.options),
|
||||
};
|
||||
if (opts.name)
|
||||
body.name = opts.name;
|
||||
(0, field_normalize_1.normalizeFieldRo)(body);
|
||||
const field = await ctx.client.convertField(tableId, fieldId, body);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(field);
|
||||
else
|
||||
process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('visible-choices <tableId> <recordId> <fieldId>')
|
||||
.description('Evaluate select-choice visibility for a record (respects choice visibilityFilter dependencies)')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const [tableId, recordId, fieldId] = args;
|
||||
const res = await ctx.client.getVisibleChoices(tableId, recordId, fieldId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(res);
|
||||
else {
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'color', header: 'COLOR' },
|
||||
], res.visibleChoices.map((c) => ({ ...c, color: c.color ?? '' })));
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('delete <tableId> <fieldId>')
|
||||
.description('Delete a field')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const [tableId, fieldId] = args;
|
||||
await ctx.client.deleteField(tableId, fieldId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)({ ok: true, id: fieldId });
|
||||
else
|
||||
process.stdout.write(`✓ Field ${fieldId} deleted\n`);
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
// ===================== record =====================
|
||||
function recordCmd() {
|
||||
const cmd = new commander_1.Command('record');
|
||||
cmd.description('Manage records (rows) in a table');
|
||||
cmd
|
||||
.command('get')
|
||||
.description('Query records with filters, pagination, and search')
|
||||
.requiredOption('--table-id <id>', 'Table id')
|
||||
.option('--filter <json>', 'Filter conditions JSON')
|
||||
.option('--projection <json>', 'Field projection JSON array')
|
||||
.option('--page-size <n>', 'Records per page')
|
||||
.option('--page <n>', 'Page number (1-based)')
|
||||
.option('--search <kw>', 'Search keyword')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
// validate baseId is present (even though getRecords only needs tableId,
|
||||
// we follow the convention that base-scoped commands require baseId config)
|
||||
(0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const query = {};
|
||||
if (opts.filter)
|
||||
query.filter = JSON.parse(opts.filter);
|
||||
if (opts.projection)
|
||||
query.projection = JSON.parse(opts.projection);
|
||||
if (opts.search)
|
||||
query.search = opts.search;
|
||||
const page = opts.page ? parseInt(opts.page, 10) : undefined;
|
||||
const pageSize = opts.pageSize ? parseInt(opts.pageSize, 10) : undefined;
|
||||
if (pageSize)
|
||||
query.take = pageSize;
|
||||
if (page) {
|
||||
const take = pageSize || 100;
|
||||
query.skip = (page - 1) * take;
|
||||
if (!pageSize)
|
||||
query.take = take;
|
||||
}
|
||||
const result = (await ctx.client.getRecords(opts.tableId, query));
|
||||
if (ctx.json) {
|
||||
(0, output_1.printJson)(result);
|
||||
}
|
||||
else {
|
||||
if (!result.records?.length) {
|
||||
process.stdout.write('No records found.\n');
|
||||
return;
|
||||
}
|
||||
const firstFields = result.records[0].fields || {};
|
||||
const fieldKeys = Object.keys(firstFields);
|
||||
const columns = fieldKeys.map((k) => ({ key: k, header: k }));
|
||||
(0, output_1.printTable)(columns, result.records.map((rec) => {
|
||||
const f = rec.fields || {};
|
||||
const row = { id: (0, output_1.truncate)(rec.id) };
|
||||
for (const k of fieldKeys) {
|
||||
row[k] = (0, output_1.truncate)(f[k], 30);
|
||||
}
|
||||
return row;
|
||||
}));
|
||||
if (result.total !== undefined) {
|
||||
process.stdout.write(`\n${result.records.length} of ${result.total} record(s).\n`);
|
||||
}
|
||||
else {
|
||||
process.stdout.write(`\n${result.records.length} record(s).\n`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('get-one <recordId>')
|
||||
.description('Get a single record by id')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const recordId = args[0];
|
||||
const record = await ctx.client.getRecord(recordId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(record);
|
||||
else {
|
||||
const rec = record;
|
||||
const fields = rec.fields || {};
|
||||
(0, output_1.printKv)([
|
||||
['id', rec.id],
|
||||
...Object.entries(fields).map(([k, v]) => [k, v]),
|
||||
]);
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('create')
|
||||
.description('Create record(s). Use --fields for a single record, --records for bulk.')
|
||||
.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"}}]\'')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
let body;
|
||||
if (opts.records) {
|
||||
body = { records: JSON.parse(opts.records) };
|
||||
}
|
||||
else if (opts.fields) {
|
||||
body = { fields: JSON.parse(opts.fields) };
|
||||
}
|
||||
else {
|
||||
throw Object.assign(new Error('Provide --fields or --records'), {
|
||||
code: 'MISSING_FIELDS',
|
||||
});
|
||||
}
|
||||
const result = await ctx.client.createRecord(opts.tableId, body);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(result);
|
||||
else
|
||||
process.stdout.write(`✓ Record(s) created\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('update')
|
||||
.description('Batch update records')
|
||||
.requiredOption('--table-id <id>', 'Table id')
|
||||
.requiredOption('--records <json>', 'Records JSON, e.g. \'[{"id":"recX","fields":{"Title":"b"}}]\'')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
const body = { records: JSON.parse(opts.records) };
|
||||
const result = await ctx.client.updateRecord(opts.tableId, body);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(result);
|
||||
else
|
||||
process.stdout.write(`✓ Records updated\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('delete')
|
||||
.description('Batch delete records by ids')
|
||||
.requiredOption('--table-id <id>', 'Table id')
|
||||
.requiredOption('--record-ids <csv>', 'Record ids (comma-separated)')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
const ids = opts.recordIds
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const result = await ctx.client.deleteRecords(opts.tableId, ids);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(result);
|
||||
else
|
||||
process.stdout.write(`✓ ${ids.length} record(s) deleted\n`);
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
// ===================== view =====================
|
||||
function viewCmd() {
|
||||
const cmd = new commander_1.Command('view');
|
||||
cmd.description('Manage views in a table');
|
||||
cmd
|
||||
.command('list <tableId>')
|
||||
.description('List views in a table')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const tableId = args[0];
|
||||
const list = (await ctx.client.listViews(tableId));
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(list);
|
||||
else {
|
||||
if (!list.length) {
|
||||
process.stdout.write('No views found.\n');
|
||||
return;
|
||||
}
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'type', header: 'TYPE' },
|
||||
], list.map((v) => ({
|
||||
id: (0, output_1.truncate)(v.id),
|
||||
name: (0, output_1.truncate)(v.name, 30),
|
||||
type: v.type,
|
||||
})));
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('get <viewId>')
|
||||
.description('Get view details')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const viewId = args[0];
|
||||
const view = await ctx.client.getView(viewId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(view);
|
||||
else {
|
||||
const v = view;
|
||||
(0, output_1.printKv)(Object.entries(v).map(([k, val]) => [k, val]));
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('create')
|
||||
.description('Create a new view')
|
||||
.requiredOption('--table-id <id>', 'Table id')
|
||||
.requiredOption('--name <name>', 'View name')
|
||||
.option('--type <type>', 'View type (grid, calendar, kanban, gallery, form)', 'grid')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
const body = { name: opts.name, type: opts.type };
|
||||
const view = await ctx.client.createView(opts.tableId, body);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(view);
|
||||
else {
|
||||
const v = view;
|
||||
(0, output_1.printKv)([
|
||||
['id', v.id],
|
||||
['name', v.name],
|
||||
['type', v.type],
|
||||
]);
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('update <viewId>')
|
||||
.description('Update a view (rename)')
|
||||
.option('--name <name>', 'New view name')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const viewId = args[0];
|
||||
const opts = o;
|
||||
const body = {};
|
||||
if (opts.name)
|
||||
body.name = opts.name;
|
||||
const view = await ctx.client.updateView(viewId, body);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(view);
|
||||
else
|
||||
process.stdout.write(`✓ View ${viewId} updated\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('delete <viewId>')
|
||||
.description('Delete a view')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const viewId = args[0];
|
||||
await ctx.client.deleteView(viewId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)({ ok: true, id: viewId });
|
||||
else
|
||||
process.stdout.write(`✓ View ${viewId} deleted\n`);
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
Vendored
-636
@@ -1,636 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildActionConfig = exports.actionCommand = exports.buildTriggerConfig = exports.triggerCommand = exports.workflowCommand = void 0;
|
||||
/**
|
||||
* `inkreach-teable workflow` — workflow CRUD + lifecycle.
|
||||
* `inkreach-teable trigger` — set the trigger node's type & config.
|
||||
* `inkreach-teable action` — add/update/delete action nodes.
|
||||
*
|
||||
* All node-mutating commands operate via PUT /node (whole-graph replacement),
|
||||
* so they internally GET the full graph, mutate, then PUT it back.
|
||||
*/
|
||||
const commander_1 = require("commander");
|
||||
const ctx_1 = require("../lib/ctx");
|
||||
const output_1 = require("../lib/output");
|
||||
// ===================== workflow =====================
|
||||
function workflowCommand() {
|
||||
const cmd = new commander_1.Command('workflow');
|
||||
cmd.description('Manage automations (workflows) — the self-built automation engine');
|
||||
cmd
|
||||
.command('list')
|
||||
.description('List all workflows in the base')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const list = (await ctx.client.listWorkflows(baseId));
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(list);
|
||||
else {
|
||||
if (!list.length) {
|
||||
process.stdout.write('No workflows found.\n');
|
||||
return;
|
||||
}
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'isActive', header: 'ACTIVE' },
|
||||
{ key: 'hasDraft', header: 'DRAFT' },
|
||||
{ key: 'nodes', header: 'NODES' },
|
||||
], list.map((w) => ({
|
||||
id: w.id,
|
||||
name: (0, output_1.truncate)(w.name, 30),
|
||||
isActive: w.isActive ? '✓' : '',
|
||||
hasDraft: w.hasDraft ? '*' : '',
|
||||
nodes: (w.nodes || []).length,
|
||||
})));
|
||||
process.stdout.write(`\n${list.length} workflow(s). Use 'workflow get <id>' for details.\n`);
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('get <workflowId>')
|
||||
.description('Get workflow detail (nodes + edges)')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const workflowId = args[0];
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(wf);
|
||||
else {
|
||||
(0, output_1.printKv)([
|
||||
['id', wf.id],
|
||||
['name', wf.name],
|
||||
['description', wf.description || ''],
|
||||
['active', wf.isActive ? 'yes' : 'no'],
|
||||
['hasDraft', wf.hasDraft ? 'yes' : 'no'],
|
||||
]);
|
||||
process.stdout.write('\nNODES:\n');
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'type', header: 'TYPE' },
|
||||
{ key: 'category', header: 'CATEGORY' },
|
||||
{ key: 'config', header: 'CONFIG' },
|
||||
], (wf.nodes || []).map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
category: n.category,
|
||||
config: (0, output_1.truncate)(JSON.stringify(n.config), 40),
|
||||
})));
|
||||
if (wf.edges?.length) {
|
||||
process.stdout.write('\nEDGES:\n');
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'sourceNodeId', header: 'SOURCE' },
|
||||
{ key: 'targetNodeId', header: 'TARGET' },
|
||||
], (wf.edges || []).map((e) => ({ ...e })));
|
||||
}
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('create')
|
||||
.description('Create a workflow (auto-includes a recordCreated trigger node)')
|
||||
.requiredOption('--name <name>', 'Workflow name')
|
||||
.option('--description <desc>', 'Workflow description')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const wf = await ctx.client.createWorkflow(baseId, {
|
||||
name: opts.name,
|
||||
description: opts.description,
|
||||
});
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(wf);
|
||||
else {
|
||||
(0, output_1.printKv)([
|
||||
['id', wf.id],
|
||||
['name', wf.name],
|
||||
['baseId', wf.baseId],
|
||||
['', ''],
|
||||
['next', `inkreach-teable trigger set ${wf.id} --type <T> ...`],
|
||||
]);
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('update <workflowId>')
|
||||
.description('Update workflow name / description')
|
||||
.option('--name <name>', 'New name')
|
||||
.option('--description <desc>', 'New description')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const workflowId = args[0];
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const wf = await ctx.client.updateWorkflow(baseId, workflowId, {
|
||||
name: opts.name,
|
||||
description: opts.description,
|
||||
});
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(wf);
|
||||
else
|
||||
process.stdout.write(`✓ Workflow ${workflowId} updated\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('delete <workflowId>')
|
||||
.description('Soft-delete a workflow (cascades: base_node, auto-token, trigger unregister)')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const workflowId = args[0];
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
await ctx.client.deleteWorkflow(baseId, workflowId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)({ ok: true, id: workflowId });
|
||||
else
|
||||
process.stdout.write(`✓ Workflow ${workflowId} deleted\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('activate <workflowId>')
|
||||
.description('Activate: snapshot + register trigger + create auto access token')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const workflowId = args[0];
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const res = await ctx.client.activateWorkflow(baseId, workflowId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)({ ok: true, ...res });
|
||||
else
|
||||
(0, output_1.printKv)([
|
||||
['snapshotId', res.snapshotId],
|
||||
['status', 'activated'],
|
||||
]);
|
||||
}));
|
||||
cmd
|
||||
.command('deactivate <workflowId>')
|
||||
.description('Deactivate: unregister trigger')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const workflowId = args[0];
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
await ctx.client.deactivateWorkflow(baseId, workflowId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)({ ok: true, id: workflowId });
|
||||
else
|
||||
process.stdout.write(`✓ Workflow ${workflowId} deactivated\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('test <workflowId>')
|
||||
.description('Test-run: execute action nodes with mock input (no trigger, no queue)')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const workflowId = args[0];
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const res = await ctx.client.testWorkflow(baseId, workflowId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(res);
|
||||
else {
|
||||
process.stdout.write('Test results:\n');
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'nodeId', header: 'NODE' },
|
||||
{ key: 'status', header: 'STATUS' },
|
||||
{ key: 'error', header: 'ERROR' },
|
||||
], (res.nodeResults || []).map((r) => {
|
||||
const row = r;
|
||||
return {
|
||||
nodeId: row.nodeId,
|
||||
status: row.status,
|
||||
error: (0, output_1.truncate)(row.error, 50),
|
||||
};
|
||||
}));
|
||||
}
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
exports.workflowCommand = workflowCommand;
|
||||
// ===================== trigger =====================
|
||||
const TRIGGER_TYPES = [
|
||||
'recordCreated',
|
||||
'recordUpdated',
|
||||
'recordCondition',
|
||||
'recordCreatedOrUpdated',
|
||||
'formSubmitted',
|
||||
'scheduledTime',
|
||||
'buttonClick',
|
||||
'webhook',
|
||||
];
|
||||
function triggerCommand() {
|
||||
const cmd = new commander_1.Command('trigger');
|
||||
cmd.description('Set the trigger node (type + config). Builds correct config keys for you.');
|
||||
cmd
|
||||
.command('set <workflowId>')
|
||||
.description('Set the trigger type and config (replaces the existing trigger node)')
|
||||
.requiredOption('--type <type>', `Trigger type: ${TRIGGER_TYPES.join('|')}`)
|
||||
.option('--table-id <id>', 'Table id (most record/button triggers)')
|
||||
.option('--field-ids <json>', 'Field ids JSON array, e.g. \'["fldXXX"]\' (recordUpdated, buttonClick)')
|
||||
.option('--form-id <id>', 'Form view id (formSubmitted, legacy single). Prefer --form-ids.')
|
||||
.option('--form-ids <csv>', 'Comma-separated form view ids (formSubmitted, multi-select)')
|
||||
.option('--conditions <json>', 'Filter conditions JSON (recordCondition/recordCreated)')
|
||||
.option('--schedule <json>', 'Schedule JSON {timing:{type,interval}} (scheduledTime)')
|
||||
.option('--batch <json>', 'Batch config JSON {enabled,batchWindow,batchSize}')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const workflowId = args[0];
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
if (!TRIGGER_TYPES.includes(opts.type)) {
|
||||
throw Object.assign(new Error(`Invalid trigger type: ${opts.type}`), {
|
||||
code: 'INVALID_TYPE',
|
||||
});
|
||||
}
|
||||
const config = buildTriggerConfig(opts.type, {
|
||||
tableId: opts.tableId,
|
||||
fieldIds: opts.fieldIds,
|
||||
formId: opts.formId,
|
||||
formIds: opts.formIds,
|
||||
conditions: opts.conditions,
|
||||
schedule: opts.schedule,
|
||||
batch: opts.batch,
|
||||
});
|
||||
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||
const nodes = (wf.nodes || []).slice();
|
||||
// Find the trigger node (there is exactly one) and replace its category + config.
|
||||
const triggerIdx = nodes.findIndex((n) => n.type === 'trigger');
|
||||
const newTrigger = {
|
||||
...nodes[triggerIdx >= 0 ? triggerIdx : 0],
|
||||
type: 'trigger',
|
||||
category: opts.type,
|
||||
config,
|
||||
};
|
||||
if (triggerIdx >= 0)
|
||||
nodes[triggerIdx] = newTrigger;
|
||||
else
|
||||
nodes.unshift(newTrigger);
|
||||
const updated = await ctx.client.replaceNodes(baseId, workflowId, {
|
||||
nodes,
|
||||
edges: wf.edges || [],
|
||||
});
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(updated);
|
||||
else {
|
||||
(0, output_1.printKv)([
|
||||
['workflowId', workflowId],
|
||||
['triggerType', opts.type],
|
||||
['config', JSON.stringify(config)],
|
||||
['', ''],
|
||||
['next', `inkreach-teable action add ${workflowId} --type script ...`],
|
||||
]);
|
||||
}
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
exports.triggerCommand = triggerCommand;
|
||||
/** Build the trigger node config JSON using the SELF-BUILT contract keys. */
|
||||
function buildTriggerConfig(type, opts) {
|
||||
const parse = (s) => (s ? JSON.parse(s) : undefined);
|
||||
const config = {};
|
||||
switch (type) {
|
||||
case 'recordCreated':
|
||||
case 'recordCreatedOrUpdated': {
|
||||
if (opts.tableId)
|
||||
config.tableId = opts.tableId;
|
||||
if (opts.conditions)
|
||||
config.conditions = parse(opts.conditions);
|
||||
break;
|
||||
}
|
||||
case 'recordUpdated': {
|
||||
if (opts.tableId)
|
||||
config.tableId = opts.tableId;
|
||||
// NOTE: key is `fieldIds` (NOT watchFieldIds). [] = watch all fields.
|
||||
config.fieldIds = opts.fieldIds ? parse(opts.fieldIds) : [];
|
||||
break;
|
||||
}
|
||||
case 'recordCondition': {
|
||||
if (opts.tableId)
|
||||
config.tableId = opts.tableId;
|
||||
// conditions is required for recordCondition
|
||||
config.conditions = opts.conditions
|
||||
? parse(opts.conditions)
|
||||
: { conjunction: 'and', filterSet: [] };
|
||||
break;
|
||||
}
|
||||
case 'formSubmitted': {
|
||||
if (opts.tableId)
|
||||
config.tableId = opts.tableId;
|
||||
// Prefer --form-ids (csv) which populates formIds:string[]. Fall back to
|
||||
// legacy --form-id (single string) for backward compatibility.
|
||||
if (opts.formIds) {
|
||||
config.formIds = opts.formIds
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
else if (opts.formId) {
|
||||
config.formId = opts.formId;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'scheduledTime': {
|
||||
// Backend ONLY honors timing.type + timing.interval (rest is ignored).
|
||||
if (opts.schedule) {
|
||||
const sched = parse(opts.schedule);
|
||||
if (sched.timing)
|
||||
config.timing = sched.timing;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'buttonClick': {
|
||||
if (opts.tableId)
|
||||
config.tableId = opts.tableId;
|
||||
config.fieldIds = opts.fieldIds ? parse(opts.fieldIds) : [];
|
||||
break;
|
||||
}
|
||||
case 'webhook': {
|
||||
// authorization is UI-only; backend does NOT validate it.
|
||||
config.authorization = { type: 'none' };
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (opts.batch &&
|
||||
['recordCreated', 'recordUpdated', 'recordCondition', 'recordCreatedOrUpdated'].includes(type)) {
|
||||
config.batch = parse(opts.batch);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
exports.buildTriggerConfig = buildTriggerConfig;
|
||||
// ===================== action =====================
|
||||
const ACTION_TYPES = [
|
||||
'script',
|
||||
'sendEmail',
|
||||
'httpRequest',
|
||||
'createRecord',
|
||||
'updateRecord',
|
||||
'getRecords',
|
||||
'sendNotification',
|
||||
];
|
||||
function actionCommand() {
|
||||
const cmd = new commander_1.Command('action');
|
||||
cmd.description('Add/update/delete action nodes');
|
||||
cmd
|
||||
.command('add <workflowId>')
|
||||
.description('Add an action node after the last existing node')
|
||||
.requiredOption('--type <type>', `Action type: ${ACTION_TYPES.join('|')}`)
|
||||
.option('--config <json>', 'Full action config JSON (overrides the granular flags below)')
|
||||
.option('--code <js>', 'Script JS code (script)')
|
||||
.option('--code-file <path>', 'Read script code from a file (script)')
|
||||
.option('--timeout <ms>', 'Script timeout ms (script, default 60000)')
|
||||
.option('--dependencies <json>', 'npm packages JSON array for script, e.g. \'[{"name":"axios","version":"1.6.0"}]\' (script)')
|
||||
.option('--to <email>', 'Recipient (sendEmail)')
|
||||
.option('--subject <s>', 'Subject (sendEmail)')
|
||||
.option('--body <html>', 'HTML body (sendEmail)')
|
||||
.option('--url <u>', 'Request URL (httpRequest)')
|
||||
.option('--method <m>', 'HTTP method (httpRequest, default GET)')
|
||||
.option('--headers <json>', 'Request headers JSON (httpRequest)')
|
||||
.option('--req-body <json>', 'Request body JSON (httpRequest)')
|
||||
.option('--target-table <id>', 'Target table id (createRecord/updateRecord/getRecords)')
|
||||
.option('--records <json>', 'Records JSON array (createRecord/updateRecord)')
|
||||
.option('--query <json>', 'Query JSON (getRecords)')
|
||||
.option('--to-user <id>', 'Recipient user id (sendNotification)')
|
||||
.option('--title <s>', 'Notification title (sendNotification)')
|
||||
.option('--message <s>', 'Notification message (sendNotification)')
|
||||
.option('--severity <s>', 'Notification severity info|warning|critical (sendNotification)')
|
||||
.option('--path <s>', 'Click-through path, e.g. /base/xx/table/yy (sendNotification)')
|
||||
.option('--name <n>', 'Optional node display name')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const workflowId = args[0];
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
if (!ACTION_TYPES.includes(opts.type)) {
|
||||
throw Object.assign(new Error(`Invalid action type: ${opts.type}`), {
|
||||
code: 'INVALID_TYPE',
|
||||
});
|
||||
}
|
||||
const config = opts.config ? JSON.parse(opts.config) : buildActionConfig(opts);
|
||||
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||
const nodes = (wf.nodes || []).slice();
|
||||
const edges = (wf.edges || []).slice();
|
||||
const newNodeId = `node-${Date.now()}`;
|
||||
const newNode = {
|
||||
id: newNodeId,
|
||||
type: 'action',
|
||||
category: opts.type,
|
||||
config,
|
||||
name: opts.name || null,
|
||||
};
|
||||
// Chain after the last node (trigger → ... → new).
|
||||
const lastNode = nodes[nodes.length - 1];
|
||||
nodes.push(newNode);
|
||||
if (lastNode) {
|
||||
edges.push({
|
||||
id: `edge-${lastNode.id}-${newNodeId}`,
|
||||
sourceNodeId: lastNode.id,
|
||||
targetNodeId: newNodeId,
|
||||
});
|
||||
}
|
||||
const updated = await ctx.client.replaceNodes(baseId, workflowId, { nodes, edges });
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(updated);
|
||||
else {
|
||||
(0, output_1.printKv)([
|
||||
['workflowId', workflowId],
|
||||
['nodeId', newNodeId],
|
||||
['type', opts.type],
|
||||
['config', JSON.stringify(config)],
|
||||
['', ''],
|
||||
['next', `inkreach-teable workflow activate ${workflowId}`],
|
||||
]);
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('update <workflowId> <nodeId>')
|
||||
.description('Update an existing action node config')
|
||||
.option('--config <json>', 'Full replacement config JSON')
|
||||
.option('--code <js>', 'Script JS code (script)')
|
||||
.option('--code-file <path>', 'Read script code from a file (script)')
|
||||
.option('--timeout <ms>', 'Script timeout ms (script)')
|
||||
.option('--dependencies <json>', 'npm packages JSON array for script, e.g. \'[{"name":"axios","version":"1.6.0"}]\' (script; replaces existing deps)')
|
||||
.option('--name <n>', 'New node display name')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const [workflowId, nodeId] = args;
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||
const nodes = (wf.nodes || []).slice();
|
||||
const idx = nodes.findIndex((n) => n.id === nodeId);
|
||||
if (idx < 0)
|
||||
throw Object.assign(new Error(`Node ${nodeId} not found`), { code: 'NOT_FOUND' });
|
||||
const node = nodes[idx];
|
||||
let config = node.config || {};
|
||||
if (opts.config)
|
||||
config = JSON.parse(opts.config);
|
||||
else {
|
||||
// Granular update: merge only the flags the user actually passed.
|
||||
config = { ...config };
|
||||
// commander stores --kebab-case flags camelCased (--code-file ->
|
||||
// opts.codeFile); accept both forms like buildActionConfig's pick().
|
||||
const codeFile = opts['code-file'] ?? opts.codeFile;
|
||||
if (opts.code !== undefined || codeFile !== undefined) {
|
||||
config.code = codeFile ? readFile(codeFile) : opts.code;
|
||||
}
|
||||
if (opts.timeout !== undefined)
|
||||
config.timeout = Number(opts.timeout);
|
||||
if (opts.dependencies !== undefined) {
|
||||
config.dependencies = parseDependencies(opts.dependencies);
|
||||
}
|
||||
}
|
||||
nodes[idx] = { ...node, config, name: opts.name ?? node.name };
|
||||
const updated = await ctx.client.replaceNodes(baseId, workflowId, {
|
||||
nodes,
|
||||
edges: wf.edges || [],
|
||||
});
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(updated);
|
||||
else
|
||||
process.stdout.write(`✓ Node ${nodeId} updated\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('delete <workflowId> <nodeId>')
|
||||
.description('Delete an action node and its connected edges')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const [workflowId, nodeId] = args;
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
const wf = await ctx.client.getWorkflowInBase(baseId, workflowId);
|
||||
const nodes = (wf.nodes || []).filter((n) => n.id !== nodeId);
|
||||
const edges = (wf.edges || []).filter((e) => e.sourceNodeId !== nodeId && e.targetNodeId !== nodeId);
|
||||
const updated = await ctx.client.replaceNodes(baseId, workflowId, { nodes, edges });
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(updated);
|
||||
else
|
||||
process.stdout.write(`✓ Node ${nodeId} deleted\n`);
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
exports.actionCommand = actionCommand;
|
||||
function readFile(p) {
|
||||
// inline require to keep imports clean
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('node:fs').readFileSync(p, 'utf8');
|
||||
}
|
||||
/**
|
||||
* Parse `--dependencies` into the ScriptDependency[] shape the backend expects.
|
||||
* Accepts:
|
||||
* - full JSON: '[{"name":"axios","version":"1.6.0"}]'
|
||||
* - name list: 'axios,lodash' (version defaults to 'latest')
|
||||
* - name@ver: 'axios@1.6.0,lodash@4.17.21'
|
||||
* - empty '[]' or '' to clear deps
|
||||
*/
|
||||
function parseDependencies(raw) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || trimmed === '[]')
|
||||
return [];
|
||||
// JSON form
|
||||
if (trimmed[0] === '[') {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!Array.isArray(parsed))
|
||||
throw new Error('--dependencies JSON must be an array');
|
||||
return parsed.map((d) => {
|
||||
if (typeof d === 'string')
|
||||
return { name: d };
|
||||
if (d && typeof d === 'object' && typeof d.name === 'string') {
|
||||
return { name: d.name, ...(d.version ? { version: String(d.version) } : {}) };
|
||||
}
|
||||
throw new Error('--dependencies entries must be strings or {name,version?} objects');
|
||||
});
|
||||
}
|
||||
// Comma list form: axios@1.6.0,lodash
|
||||
return trimmed
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((spec) => {
|
||||
const at = spec.lastIndexOf('@');
|
||||
if (at > 0)
|
||||
return { name: spec.slice(0, at), version: spec.slice(at + 1) };
|
||||
return { name: spec };
|
||||
});
|
||||
}
|
||||
/** Build action config from granular flags. */
|
||||
function buildActionConfig(opts) {
|
||||
const type = opts.type;
|
||||
// Helper: commander stores --kebab-case flags as camelCase in opts (e.g.
|
||||
// --target-table -> opts.targetTable). Accept both forms for safety.
|
||||
const pick = (k) => (opts[k] ?? opts[toCamel(k)]);
|
||||
const config = {};
|
||||
switch (type) {
|
||||
case 'script': {
|
||||
const codeFile = pick('code-file');
|
||||
if (codeFile)
|
||||
config.code = readFile(codeFile);
|
||||
else if (opts.code)
|
||||
config.code = opts.code;
|
||||
if (opts.timeout)
|
||||
config.timeout = Number(opts.timeout);
|
||||
if (opts.dependencies)
|
||||
config.dependencies = parseDependencies(opts.dependencies);
|
||||
break;
|
||||
}
|
||||
case 'sendEmail': {
|
||||
if (opts.to)
|
||||
config.to = opts.to;
|
||||
if (opts.subject)
|
||||
config.subject = opts.subject;
|
||||
if (opts.body)
|
||||
config.body = opts.body;
|
||||
break;
|
||||
}
|
||||
case 'httpRequest': {
|
||||
if (opts.url)
|
||||
config.url = opts.url;
|
||||
if (opts.method)
|
||||
config.method = opts.method;
|
||||
if (opts.headers)
|
||||
config.headers = JSON.parse(opts.headers);
|
||||
const reqBody = pick('req-body');
|
||||
if (reqBody)
|
||||
config.body = JSON.parse(reqBody);
|
||||
break;
|
||||
}
|
||||
case 'createRecord':
|
||||
case 'updateRecord': {
|
||||
const tt = pick('target-table');
|
||||
if (tt)
|
||||
config.tableId = tt;
|
||||
if (opts.records)
|
||||
config.records = JSON.parse(opts.records);
|
||||
break;
|
||||
}
|
||||
case 'getRecords': {
|
||||
const tt = pick('target-table');
|
||||
if (tt)
|
||||
config.tableId = tt;
|
||||
if (opts.query)
|
||||
config.query = JSON.parse(opts.query);
|
||||
break;
|
||||
}
|
||||
case 'sendNotification': {
|
||||
if (opts.toUser)
|
||||
config.toUserId = opts.toUser;
|
||||
if (opts.title)
|
||||
config.title = opts.title;
|
||||
if (opts.message)
|
||||
config.message = opts.message;
|
||||
if (opts.severity)
|
||||
config.severity = opts.severity;
|
||||
if (opts.path)
|
||||
config.path = opts.path;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
exports.buildActionConfig = buildActionConfig;
|
||||
function toCamel(s) {
|
||||
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
}
|
||||
Vendored
-275
@@ -1,275 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ApiError = exports.Client = void 0;
|
||||
/**
|
||||
* High-level API client. Each method maps to one REST endpoint of the
|
||||
* inkreach-teable backend, using the SELF-BUILT contract (not official teable).
|
||||
*
|
||||
* Base/table/field/record/view endpoints are compatible with official teable
|
||||
* open-api; workflow/automation endpoints use the project's own contract.
|
||||
*/
|
||||
const request_1 = require("./request");
|
||||
Object.defineProperty(exports, "ApiError", { enumerable: true, get: function () { return request_1.ApiError; } });
|
||||
class Client {
|
||||
cfg;
|
||||
constructor(cfg) {
|
||||
this.cfg = cfg;
|
||||
}
|
||||
get headers() {
|
||||
return this.cfg.token ? { Authorization: `Bearer ${this.cfg.token}` } : {};
|
||||
}
|
||||
async call(method, p, body) {
|
||||
const res = await (0, request_1.request)(this.cfg.endpoint, p, { method, headers: this.headers, body });
|
||||
return res.data;
|
||||
}
|
||||
// ---- auth / user ----
|
||||
getMe() {
|
||||
// GET /api/auth/user — @TokenAccess() enabled, works with PAT.
|
||||
// (/api/auth/user/me is session-only and 403s for PAT.)
|
||||
return this.call('GET', '/api/auth/user');
|
||||
}
|
||||
// ---- access tokens (PAT) ----
|
||||
listAccessTokens() {
|
||||
return this.call('GET', '/api/access-token');
|
||||
}
|
||||
createAccessToken(body) {
|
||||
return this.call('POST', '/api/access-token', body);
|
||||
}
|
||||
deleteAccessToken(id) {
|
||||
return this.call('DELETE', `/api/access-token/${id}`);
|
||||
}
|
||||
// ---- base ----
|
||||
listBases() {
|
||||
// GET /api/base/access/all — lists bases the user can access.
|
||||
return this.call('GET', '/api/base/access/all');
|
||||
}
|
||||
getBase(baseId) {
|
||||
return this.call('GET', `/api/base/${baseId}`);
|
||||
}
|
||||
// ---- table ----
|
||||
listTables(baseId) {
|
||||
return this.call('GET', `/api/base/${baseId}/table`);
|
||||
}
|
||||
getTable(tableId) {
|
||||
return this.call('GET', `/api/table/${tableId}`);
|
||||
}
|
||||
createTable(baseId, body) {
|
||||
return this.call('POST', `/api/base/${baseId}/table`, body);
|
||||
}
|
||||
updateTable(tableId, body) {
|
||||
return this.call('PUT', `/api/table/${tableId}`, body);
|
||||
}
|
||||
deleteTable(tableId, baseId) {
|
||||
const base = baseId ?? this.cfg.baseId;
|
||||
return this.call('DELETE', `/api/base/${base}/table/${tableId}`);
|
||||
}
|
||||
// ---- field ----
|
||||
listFields(tableId) {
|
||||
return this.call('GET', `/api/table/${tableId}/field`);
|
||||
}
|
||||
getField(tableId, fieldId) {
|
||||
return this.call('GET', `/api/table/${tableId}/field/${fieldId}`);
|
||||
}
|
||||
createField(tableId, body) {
|
||||
return this.call('POST', `/api/table/${tableId}/field`, body);
|
||||
}
|
||||
/** Rename/describe. NOTE: the update API accepts name/description only —
|
||||
* options (incl. select choice visibilityFilter) must go through convertField. */
|
||||
updateField(tableId, fieldId, body) {
|
||||
return this.call('PATCH', `/api/table/${tableId}/field/${fieldId}`, body);
|
||||
}
|
||||
/** PUT convert: the ONLY API path that persists field options (choices,
|
||||
* visibilityFilter dependency config, etc.). Requires the field's current type. */
|
||||
convertField(tableId, fieldId, body) {
|
||||
return this.call('PUT', `/api/table/${tableId}/field/${fieldId}/convert`, body);
|
||||
}
|
||||
deleteField(tableId, fieldId) {
|
||||
return this.call('DELETE', `/api/table/${tableId}/field/${fieldId}`);
|
||||
}
|
||||
/** Evaluate select-choice visibility dependency for one record+field.
|
||||
* GET /api/table/:tableId/select-visibility/:recordId/:fieldId */
|
||||
getVisibleChoices(tableId, recordId, fieldId) {
|
||||
return this.call('GET', `/api/table/${tableId}/select-visibility/${recordId}/${fieldId}`);
|
||||
}
|
||||
// ---- record ----
|
||||
getRecords(tableId, query) {
|
||||
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
||||
return this.call('GET', `/api/table/${tableId}/record${qs}`);
|
||||
}
|
||||
/** Flatten search[] tuple arrays into repeated query keys (Teable expects search[]=...). */
|
||||
flattenQuery(query) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v === undefined || v === null)
|
||||
continue;
|
||||
if (Array.isArray(v)) {
|
||||
v.forEach((item) => {
|
||||
out[`${k}[]`] = typeof item === 'object' ? JSON.stringify(item) : String(item);
|
||||
});
|
||||
}
|
||||
else if (typeof v === 'object') {
|
||||
out[k] = JSON.stringify(v);
|
||||
}
|
||||
else if (k === 'search' && typeof v === 'string') {
|
||||
// The records list API expects `search[]=value` (tuple), not a plain
|
||||
// `search=value` string. https://github.com/colinhacks/zod
|
||||
out['search[]'] = v;
|
||||
}
|
||||
else {
|
||||
out[k] = String(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
getRecord(recordId, query) {
|
||||
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
||||
return this.call('GET', `/api/record/${recordId}${qs}`);
|
||||
}
|
||||
createRecord(tableId, body) {
|
||||
return this.call('POST', `/api/table/${tableId}/record`, body);
|
||||
}
|
||||
updateRecord(tableId, body) {
|
||||
return this.call('PATCH', `/api/table/${tableId}/record`, body);
|
||||
}
|
||||
updateRecordOne(recordId, body) {
|
||||
return this.call('PATCH', `/api/record/${recordId}`, body);
|
||||
}
|
||||
deleteRecords(tableId, recordIds) {
|
||||
const qs = recordIds.map((id) => `recordIds=${encodeURIComponent(id)}`).join('&');
|
||||
return this.call('DELETE', `/api/table/${tableId}/record?${qs}`);
|
||||
}
|
||||
// ---- view ----
|
||||
listViews(tableId) {
|
||||
return this.call('GET', `/api/table/${tableId}/view`);
|
||||
}
|
||||
getView(viewId) {
|
||||
return this.call('GET', `/api/view/${viewId}`);
|
||||
}
|
||||
createView(tableId, body) {
|
||||
return this.call('POST', `/api/table/${tableId}/view`, body);
|
||||
}
|
||||
updateView(viewId, body) {
|
||||
return this.call('PATCH', `/api/view/${viewId}`, body);
|
||||
}
|
||||
deleteView(viewId) {
|
||||
return this.call('DELETE', `/api/view/${viewId}`);
|
||||
}
|
||||
// ---- workflow (self-built contract) ----
|
||||
listWorkflows(baseId) {
|
||||
return this.call('GET', `/api/base/${baseId}/workflow`);
|
||||
}
|
||||
getWorkflow(workflowId) {
|
||||
return this.call('GET', `/api/base/any/workflow/${workflowId}`);
|
||||
}
|
||||
/**
|
||||
* Get workflow by explicit baseId + workflowId. getWorkflow() above hits a
|
||||
* route that resolves base from the workflow itself, but some auth/permission
|
||||
* paths need the base-scoped route. Prefer this when baseId is known.
|
||||
*/
|
||||
getWorkflowInBase(baseId, workflowId) {
|
||||
return this.call('GET', `/api/base/${baseId}/workflow/${workflowId}`);
|
||||
}
|
||||
createWorkflow(baseId, body) {
|
||||
return this.call('POST', `/api/base/${baseId}/workflow`, body);
|
||||
}
|
||||
updateWorkflow(baseId, workflowId, body) {
|
||||
return this.call('PATCH', `/api/base/${baseId}/workflow/${workflowId}`, body);
|
||||
}
|
||||
deleteWorkflow(baseId, workflowId) {
|
||||
return this.call('DELETE', `/api/base/${baseId}/workflow/${workflowId}`);
|
||||
}
|
||||
activateWorkflow(baseId, workflowId) {
|
||||
return this.call('POST', `/api/base/${baseId}/workflow/${workflowId}/activate`);
|
||||
}
|
||||
deactivateWorkflow(baseId, workflowId) {
|
||||
return this.call('POST', `/api/base/${baseId}/workflow/${workflowId}/deactivate`);
|
||||
}
|
||||
testWorkflow(baseId, workflowId) {
|
||||
return this.call('POST', `/api/base/${baseId}/workflow/${workflowId}/test`);
|
||||
}
|
||||
/** PUT /node — whole-graph replacement (delete-all + create-all in a txn). */
|
||||
replaceNodes(baseId, workflowId, body) {
|
||||
return this.call('PUT', `/api/base/${baseId}/workflow/${workflowId}/node`, body);
|
||||
}
|
||||
// ---- secrets ----
|
||||
listSecrets(baseId, workflowId) {
|
||||
return this.call('GET', `/api/base/${baseId}/workflow/${workflowId}/secret`);
|
||||
}
|
||||
createSecret(baseId, workflowId, body) {
|
||||
return this.call('POST', `/api/base/${baseId}/workflow/${workflowId}/secret`, body);
|
||||
}
|
||||
updateSecret(baseId, workflowId, secretId, body) {
|
||||
return this.call('PATCH', `/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`, body);
|
||||
}
|
||||
deleteSecret(baseId, workflowId, secretId) {
|
||||
return this.call('DELETE', `/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`);
|
||||
}
|
||||
// ---- execution history ----
|
||||
listExecutions(baseId, workflowId) {
|
||||
return this.call('GET', `/api/base/${baseId}/workflow/${workflowId}/execution`);
|
||||
}
|
||||
getExecution(baseId, workflowId, executionId) {
|
||||
return this.call('GET', `/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}`);
|
||||
}
|
||||
rerunExecution(baseId, workflowId, executionId) {
|
||||
return this.call('POST', `/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}/rerun`);
|
||||
}
|
||||
// ---- trigger (manual fire for testing) ----
|
||||
fireButtonClick(tableId, recordId, fieldId) {
|
||||
return this.call('POST', `/api/table/${tableId}/record/${recordId}/${fieldId}/button-click`);
|
||||
}
|
||||
fireWebhook(baseId, workflowId, body) {
|
||||
// NOTE: real backend route is /api/webhook/{baseId}/{workflowId} (no /base/ /workflow/ segments)
|
||||
return this.call('POST', `/api/webhook/${baseId}/${workflowId}`, body);
|
||||
}
|
||||
// ---- SQL (read-only) ----
|
||||
sql(baseId, sql) {
|
||||
return this.call('POST', `/api/base/${baseId}/sql`, { sql });
|
||||
}
|
||||
// ---- plugin ----
|
||||
getPlugins() {
|
||||
return this.call('GET', '/api/plugin');
|
||||
}
|
||||
getPlugin(pluginId) {
|
||||
return this.call('GET', `/api/plugin/${encodeURIComponent(pluginId)}`);
|
||||
}
|
||||
updatePlugin(pluginId, body) {
|
||||
return this.call('PUT', `/api/plugin/${encodeURIComponent(pluginId)}`, body);
|
||||
}
|
||||
getCspOrigins() {
|
||||
return this.call('GET', '/api/plugin/csp-origins');
|
||||
}
|
||||
registerPlugin(body) {
|
||||
return this.call('POST', '/api/plugin', body);
|
||||
}
|
||||
listPluginConfigs(baseId) {
|
||||
return this.call('GET', `/api/base/${baseId}/plugin-config`);
|
||||
}
|
||||
upsertPluginConfig(baseId, pluginId, body) {
|
||||
return this.call('PUT', `/api/base/${baseId}/plugin-config/${pluginId}`, body);
|
||||
}
|
||||
// ---- plugin installs ----
|
||||
listInstalls(baseId) {
|
||||
return this.call('GET', `/api/base/${baseId}/plugin-installs`);
|
||||
}
|
||||
createInstall(baseId, body) {
|
||||
return this.call('POST', `/api/base/${baseId}/plugin-installs`, body);
|
||||
}
|
||||
deleteInstall(baseId, installId) {
|
||||
return this.call('DELETE', `/api/base/${baseId}/plugin-installs/${installId}`);
|
||||
}
|
||||
// ---- plugin developers ----
|
||||
listDevelopers() {
|
||||
return this.call('GET', '/api/plugin-developers');
|
||||
}
|
||||
addDeveloper(userIdOrEmail) {
|
||||
// Backend reads @Body('userId') or @Body('email'). Send whichever looks right.
|
||||
const isEmail = userIdOrEmail.includes('@');
|
||||
const body = isEmail ? { email: userIdOrEmail } : { userId: userIdOrEmail };
|
||||
return this.call('POST', '/api/plugin-developers', body);
|
||||
}
|
||||
removeDeveloper(userId) {
|
||||
return this.call('DELETE', `/api/plugin-developers/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
}
|
||||
exports.Client = Client;
|
||||
Vendored
-73
@@ -1,73 +0,0 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.configPath = exports.resolveConfig = exports.writeConfigFile = exports.readConfigFile = exports.DEFAULT_ENDPOINT = void 0;
|
||||
/**
|
||||
* Config storage for inkreach-teable CLI.
|
||||
*
|
||||
* Stores endpoint + token at ~/.inkreach-teable/config.json, with environment variable
|
||||
* overrides INKREACH_TEABLE_ENDPOINT / INKREACH_TEABLE_TOKEN. The --endpoint / --token / --base-id
|
||||
* global flags take the highest precedence (handled by resolveOptions()).
|
||||
*/
|
||||
const fs = __importStar(require("node:fs"));
|
||||
const path = __importStar(require("node:path"));
|
||||
const os = __importStar(require("node:os"));
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.inkreach-teable');
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
||||
exports.DEFAULT_ENDPOINT = 'http://localhost:3003';
|
||||
function readConfigFile() {
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
exports.readConfigFile = readConfigFile;
|
||||
function writeConfigFile(cfg) {
|
||||
if (!fs.existsSync(CONFIG_DIR)) {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
}
|
||||
const merged = { ...readConfigFile(), ...cfg };
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
|
||||
}
|
||||
exports.writeConfigFile = writeConfigFile;
|
||||
function resolveConfig(overrides) {
|
||||
const file = readConfigFile();
|
||||
return {
|
||||
endpoint: overrides?.endpoint ||
|
||||
process.env.INKREACH_TEABLE_ENDPOINT ||
|
||||
file.endpoint ||
|
||||
exports.DEFAULT_ENDPOINT,
|
||||
token: overrides?.token || process.env.INKREACH_TEABLE_TOKEN || file.token || '',
|
||||
baseId: overrides?.baseId || process.env.INKREACH_TEABLE_BASE_ID || file.baseId,
|
||||
};
|
||||
}
|
||||
exports.resolveConfig = resolveConfig;
|
||||
function configPath() {
|
||||
return CONFIG_FILE;
|
||||
}
|
||||
exports.configPath = configPath;
|
||||
Vendored
-72
@@ -1,72 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.requireBaseId = exports.run = exports.makeCtx = void 0;
|
||||
const config_1 = require("./config");
|
||||
const client_1 = require("./client");
|
||||
const request_1 = require("./request");
|
||||
/**
|
||||
* Build a Ctx. `command` is the subcommand being executed (so .opts() returns
|
||||
* its local options); `program` is the top-level program (for global options).
|
||||
*/
|
||||
function makeCtx(program, command) {
|
||||
const g = program.opts();
|
||||
const cfg = (0, config_1.resolveConfig)({
|
||||
endpoint: g.endpoint,
|
||||
token: g.token,
|
||||
baseId: g.baseId,
|
||||
});
|
||||
return {
|
||||
client: new client_1.Client(cfg),
|
||||
json: !!g.json,
|
||||
baseId: cfg.baseId,
|
||||
command,
|
||||
program,
|
||||
};
|
||||
}
|
||||
exports.makeCtx = makeCtx;
|
||||
/**
|
||||
* Wrap an async command action with normalized error handling.
|
||||
*
|
||||
* The wrapped fn receives (ctx, positionalArgs[], options). commander passes
|
||||
* positional args before the trailing Command; we detect the trailing Command
|
||||
* and forward everything else as positional, plus parsed options.
|
||||
*/
|
||||
function run(fn) {
|
||||
return async function (...cmdArgs) {
|
||||
// commander convention: last arg is the Command instance (the subcommand).
|
||||
const command = cmdArgs[cmdArgs.length - 1];
|
||||
const program = command?.parent ? findRoot(command) : command;
|
||||
const positional = cmdArgs.slice(0, -1);
|
||||
const options = (command?.opts() || {});
|
||||
const ctx = makeCtx(program, command);
|
||||
try {
|
||||
await fn(ctx, positional, options);
|
||||
}
|
||||
catch (err) {
|
||||
const { status, code, message } = (0, request_1.describeError)(err);
|
||||
if (ctx.json) {
|
||||
process.stdout.write(JSON.stringify({ error: { status, code, message } }) + '\n');
|
||||
}
|
||||
else {
|
||||
process.stderr.write(`✗ ${code}${status ? ` (${status})` : ''}: ${message}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
exports.run = run;
|
||||
function findRoot(cmd) {
|
||||
let c = cmd;
|
||||
while (c.parent)
|
||||
c = c.parent;
|
||||
return c;
|
||||
}
|
||||
/** Require a baseId: from flag, else ctx default, else throw with guidance. */
|
||||
function requireBaseId(ctx, flagVal) {
|
||||
const baseId = flagVal || ctx.baseId;
|
||||
if (!baseId) {
|
||||
throw new client_1.ApiError(0, 'MISSING_BASE_ID', 'No base id. Pass --base-id <id>, set a default with `inkreach-teable auth login --base-id <id>`, or export INKREACH_TEABLE_BASE_ID.');
|
||||
}
|
||||
return baseId;
|
||||
}
|
||||
exports.requireBaseId = requireBaseId;
|
||||
Vendored
-289
@@ -1,289 +0,0 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Field-definition normalization for `field create` / `table create --fields`.
|
||||
*
|
||||
* The raw API is strict: `type` must be the full enum value and select
|
||||
* `choices` must be objects (`{name, ...}`) — plain strings 400. These helpers
|
||||
* accept the friendlier documented forms (aliases like `sel`, string choices,
|
||||
* `foreignTableName` / `linkFieldName` / `lookupFieldName` to be resolved to
|
||||
* IDs) so the examples in the skill docs actually work.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.resolveFieldReferences = exports.findFieldByNameOrId = exports.findTableByNameOrId = exports.inferFieldType = exports.expandCompactFieldSpec = exports.normalizeFieldRoArray = exports.normalizeFieldRo = exports.resolveFieldType = void 0;
|
||||
const TYPE_ALIASES = {
|
||||
text: 'singleLineText',
|
||||
str: 'singleLineText',
|
||||
long: 'longText',
|
||||
num: 'number',
|
||||
int: 'number',
|
||||
check: 'checkbox',
|
||||
bool: 'checkbox',
|
||||
sel: 'singleSelect',
|
||||
select: 'singleSelect',
|
||||
multi: 'multipleSelect',
|
||||
tags: 'multipleSelect',
|
||||
rate: 'rating',
|
||||
star: 'rating',
|
||||
user: 'user',
|
||||
person: 'user',
|
||||
file: 'attachment',
|
||||
attach: 'attachment',
|
||||
mul: 'multipleSelect',
|
||||
link: 'link',
|
||||
rel: 'link',
|
||||
rollup: 'rollup',
|
||||
condrollup: 'conditionalRollup',
|
||||
formula: 'formula',
|
||||
auto: 'autoNumber',
|
||||
created: 'createdTime',
|
||||
modified: 'lastModifiedTime',
|
||||
createdby: 'createdBy',
|
||||
modifiedby: 'lastModifiedBy',
|
||||
button: 'button',
|
||||
};
|
||||
/** Resolve a type alias (`sel`) to its full value (`singleSelect`). Unknown values pass through untouched. */
|
||||
function resolveFieldType(type) {
|
||||
return TYPE_ALIASES[type.toLowerCase()] ?? type;
|
||||
}
|
||||
exports.resolveFieldType = resolveFieldType;
|
||||
const SELECT_TYPES = new Set(['singleSelect', 'multipleSelect']);
|
||||
/** Required-option defaults for types whose Ro schema has none — applied only
|
||||
* when the user supplied an options object that is missing required keys. */
|
||||
function applyOptionDefaults(type, options) {
|
||||
if (type === 'rating') {
|
||||
if (options.icon === undefined)
|
||||
options.icon = 'star';
|
||||
if (options.color === undefined)
|
||||
options.color = 'yellowBright';
|
||||
if (options.max === undefined)
|
||||
options.max = 5;
|
||||
}
|
||||
else if (type === 'number') {
|
||||
if (options.formatting === undefined) {
|
||||
options.formatting = { precision: 0, type: 'decimal' };
|
||||
}
|
||||
}
|
||||
else if (type === 'button') {
|
||||
if (options.label === undefined)
|
||||
options.label = 'Button';
|
||||
if (options.color === undefined)
|
||||
options.color = 'teal';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Normalize one field body in place:
|
||||
* - resolve `type` aliases
|
||||
* - convert select `options.choices` string entries to `{name}` objects
|
||||
* - backfill required-option defaults for rating/number/button
|
||||
*/
|
||||
function normalizeFieldRo(body) {
|
||||
if (typeof body.type === 'string') {
|
||||
body.type = resolveFieldType(body.type);
|
||||
}
|
||||
const type = body.type;
|
||||
if (type &&
|
||||
body.options &&
|
||||
typeof body.options === 'object' &&
|
||||
!Array.isArray(body.options)) {
|
||||
const options = body.options;
|
||||
if (SELECT_TYPES.has(type) && Array.isArray(options.choices)) {
|
||||
options.choices = options.choices.map((c) => typeof c === 'string' ? { name: c } : c);
|
||||
}
|
||||
applyOptionDefaults(type, options);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
exports.normalizeFieldRo = normalizeFieldRo;
|
||||
/** Apply normalizeFieldRo to every entry of a `--fields` array (invalid input passes through so the API reports it). */
|
||||
function normalizeFieldRoArray(fields) {
|
||||
if (!Array.isArray(fields))
|
||||
return fields;
|
||||
return fields.map((f) => typeof f === 'string'
|
||||
? expandCompactFieldSpec(f)
|
||||
: f && typeof f === 'object' && !Array.isArray(f)
|
||||
? normalizeFieldRo({ ...f })
|
||||
: f);
|
||||
}
|
||||
exports.normalizeFieldRoArray = normalizeFieldRoArray;
|
||||
/**
|
||||
* Expand the compact `--fields` string form "Name[:type[:choice,choice,…]]"
|
||||
* (e.g. "Status:sel:Todo,Done", "Notes:long", "Title") into a field body.
|
||||
* Bare names become singleLineText; a bare type token is resolved through
|
||||
* the alias map; a third segment lists select choices (strings).
|
||||
*/
|
||||
function expandCompactFieldSpec(spec) {
|
||||
const parts = spec.split(':').map((p) => p.trim());
|
||||
const name = parts[0];
|
||||
const type = parts[1] ? resolveFieldType(parts[1]) : inferFieldType(name, undefined);
|
||||
const body = { name, type };
|
||||
if (parts.length > 2 && parts[2]) {
|
||||
const choices = parts[2].split(',').map((c) => c.trim()).filter(Boolean);
|
||||
body.options = { choices: choices.map((c) => ({ name: c })) };
|
||||
}
|
||||
return body;
|
||||
}
|
||||
exports.expandCompactFieldSpec = expandCompactFieldSpec;
|
||||
const NAME_INFER_RULES = [
|
||||
[/^(status|priority|category|type|stage|level)$/i, 'singleSelect'],
|
||||
[/^(tags?|labels?)$/i, 'multipleSelect'],
|
||||
[/^(description|notes|comment|content|body)$/i, 'longText'],
|
||||
[/^(count|amount|price|quantity|total|number|age)$/i, 'number'],
|
||||
[/^(rating|score|stars?)$/i, 'rating'],
|
||||
[/^(is.+|has.+|active|enabled|done|completed)$/i, 'checkbox'],
|
||||
[/^(assignee|owner|user)$/i, 'user'],
|
||||
[/^(attachment|file|image|photo|document)$/i, 'attachment'],
|
||||
[/^(email|url)$/i, 'singleLineText'],
|
||||
[/^(created( time)?|created)$/i, 'createdTime'],
|
||||
[/^(last modified|updated)$/i, 'lastModifiedTime'],
|
||||
[/^(created by|author)$/i, 'createdBy'],
|
||||
[/^(modified by|updated by)$/i, 'lastModifiedBy'],
|
||||
];
|
||||
/**
|
||||
* Infer the field type when `--type` is omitted: first from options shape
|
||||
* (choices/max/foreignTableName/expression/workflow), then from the field
|
||||
* name, falling back to singleLineText.
|
||||
*/
|
||||
function inferFieldType(name, options) {
|
||||
if (options && typeof options === 'object' && !Array.isArray(options)) {
|
||||
const o = options;
|
||||
if (Array.isArray(o.choices))
|
||||
return 'singleSelect';
|
||||
if (typeof o.max === 'number')
|
||||
return 'rating';
|
||||
if (typeof o.foreignTableName === 'string' || typeof o.foreignTableId === 'string') {
|
||||
return 'link';
|
||||
}
|
||||
if (typeof o.expression === 'string') {
|
||||
// conditionalRollup when combined with link + condition; plain formula otherwise
|
||||
return typeof o.linkFieldId === 'string' && o.condition ? 'conditionalRollup' : 'formula';
|
||||
}
|
||||
if (o.workflow && typeof o.workflow === 'object')
|
||||
return 'button';
|
||||
}
|
||||
if (name) {
|
||||
for (const [re, type] of NAME_INFER_RULES) {
|
||||
if (re.test(name))
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return 'singleLineText';
|
||||
}
|
||||
exports.inferFieldType = inferFieldType;
|
||||
/** Find a table by ID or (exact, then case-insensitive) name. Returns undefined when not found. */
|
||||
function findTableByNameOrId(tables, nameOrId) {
|
||||
return (tables.find((t) => t.id === nameOrId) ??
|
||||
tables.find((t) => t.name === nameOrId) ??
|
||||
tables.find((t) => t.name.toLowerCase() === nameOrId.toLowerCase()));
|
||||
}
|
||||
exports.findTableByNameOrId = findTableByNameOrId;
|
||||
/** Find a field by ID or (exact, then case-insensitive) name. Returns undefined when not found. */
|
||||
function findFieldByNameOrId(fields, nameOrId) {
|
||||
return (fields.find((f) => f.id === nameOrId) ??
|
||||
fields.find((f) => f.name === nameOrId) ??
|
||||
fields.find((f) => f.name.toLowerCase() === nameOrId.toLowerCase()));
|
||||
}
|
||||
exports.findFieldByNameOrId = findFieldByNameOrId;
|
||||
/**
|
||||
* Resolve name references in link `options` and `lookupOptions` to IDs:
|
||||
* - options.foreignTableName → options.foreignTableId
|
||||
* - lookupOptions.linkFieldName → linkFieldId (fields of THIS table)
|
||||
* - lookupOptions.lookupFieldName → lookupFieldId (fields of the foreign table)
|
||||
*
|
||||
* `listTables(baseId)`, `listFields(tableId)` are client calls; only invoked when
|
||||
* a *Name key is present. Existing IDs are left untouched. Returns error strings
|
||||
* (not throws) so the caller can decide to fail with a helpful message.
|
||||
*/
|
||||
async function resolveFieldReferences(body, deps) {
|
||||
const errors = [];
|
||||
const options = (body.options && typeof body.options === 'object' && !Array.isArray(body.options)
|
||||
? body.options
|
||||
: undefined);
|
||||
if (options && typeof options.foreignTableName === 'string' && !options.foreignTableId) {
|
||||
const tables = await deps.listTables(deps.baseId);
|
||||
const t = findTableByNameOrId(tables, options.foreignTableName);
|
||||
if (!t) {
|
||||
errors.push(`foreignTableName "${options.foreignTableName}" not found in base ${deps.baseId}`);
|
||||
}
|
||||
else {
|
||||
options.foreignTableId = t.id;
|
||||
delete options.foreignTableName; // API rejects unknown keys
|
||||
}
|
||||
}
|
||||
const lookup = body.lookupOptions &&
|
||||
typeof body.lookupOptions === 'object' &&
|
||||
!Array.isArray(body.lookupOptions)
|
||||
? body.lookupOptions
|
||||
: undefined;
|
||||
if (lookup) {
|
||||
// For lookup fields the foreign table is the target of the LINK field
|
||||
// referenced by linkFieldId — there is no options.foreignTableId on the
|
||||
// lookup field itself. Resolve the link field first, then use its options.
|
||||
let linkForeignTableId;
|
||||
const linkRef = typeof lookup.linkFieldId === 'string'
|
||||
? lookup.linkFieldId
|
||||
: typeof lookup.linkFieldName === 'string'
|
||||
? lookup.linkFieldName
|
||||
: undefined;
|
||||
const hasNameHints = typeof lookup.linkFieldName === 'string' || typeof lookup.lookupFieldName === 'string';
|
||||
const needForeignFromLink = hasNameHints &&
|
||||
typeof lookup.foreignTableId !== 'string' &&
|
||||
!(options && typeof options.foreignTableId === 'string');
|
||||
if (linkRef && deps.tableId && (hasNameHints || needForeignFromLink)) {
|
||||
const fields = (await deps.listFields(deps.tableId)) ?? [];
|
||||
const linkField = findFieldByNameOrId(fields, linkRef);
|
||||
if (!linkField && typeof lookup.linkFieldId !== 'string') {
|
||||
errors.push(`linkFieldName "${lookup.linkFieldName}" not found in table ${deps.tableId}`);
|
||||
}
|
||||
if (linkField) {
|
||||
lookup.linkFieldId = linkField.id;
|
||||
const linkOptions = linkField
|
||||
.options;
|
||||
const fid = linkOptions && typeof linkOptions.foreignTableId === 'string'
|
||||
? linkOptions.foreignTableId
|
||||
: undefined;
|
||||
if (fid)
|
||||
linkForeignTableId = fid;
|
||||
}
|
||||
}
|
||||
const foreignTableId = (options && typeof options.foreignTableId === 'string' && options.foreignTableId) ||
|
||||
(typeof lookup.foreignTableId === 'string' && lookup.foreignTableId) ||
|
||||
linkForeignTableId ||
|
||||
(typeof lookup.linkFieldId === 'string' && lookup.linkFieldId); // conditional rollup: foreign table only
|
||||
// Backfill foreignTableId (Ro schema requires it even for plain lookups,
|
||||
// where it is derived from the link field) once resolved.
|
||||
if (linkForeignTableId && typeof lookup.foreignTableId !== 'string') {
|
||||
lookup.foreignTableId = linkForeignTableId;
|
||||
}
|
||||
if (typeof lookup.linkFieldName === 'string' && !lookup.linkFieldId) {
|
||||
if (!deps.tableId) {
|
||||
errors.push('linkFieldName resolution requires --table-id (creating a field)');
|
||||
}
|
||||
// (resolution already attempted above; if it failed an error was pushed)
|
||||
}
|
||||
if (typeof lookup.lookupFieldName === 'string' && !lookup.lookupFieldId) {
|
||||
const foreign = (options && typeof options.foreignTableId === 'string' && options.foreignTableId) ||
|
||||
foreignTableId;
|
||||
if (!foreign) {
|
||||
errors.push('lookupFieldName resolution requires foreignTableId (or foreignTableName)');
|
||||
}
|
||||
else {
|
||||
const fields = await deps.listFields(foreign);
|
||||
const f = findFieldByNameOrId(fields, lookup.lookupFieldName);
|
||||
if (!f) {
|
||||
errors.push(`lookupFieldName "${lookup.lookupFieldName}" not found in foreign table ${foreign}`);
|
||||
}
|
||||
else {
|
||||
lookup.lookupFieldId = f.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The API rejects unknown keys — strip the name hints (only when resolved;
|
||||
// unresolved ones produced errors above and the caller will abort).
|
||||
if (lookup.linkFieldId)
|
||||
delete lookup.linkFieldName;
|
||||
if (lookup.lookupFieldId)
|
||||
delete lookup.lookupFieldName;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
exports.resolveFieldReferences = resolveFieldReferences;
|
||||
Vendored
-34
@@ -1,34 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.truncate = exports.printKv = exports.printTable = exports.printJson = void 0;
|
||||
/** Print as JSON (compact). Safe for agent parsing. */
|
||||
function printJson(value) {
|
||||
process.stdout.write(JSON.stringify(value) + '\n');
|
||||
}
|
||||
exports.printJson = printJson;
|
||||
/** Print a human-readable table. columns: [{key, header, width?}]; rows: object[]. */
|
||||
function printTable(columns, rows) {
|
||||
const headers = columns.map((c) => c.header);
|
||||
const widths = columns.map((c) => Math.max(c.header.length, ...rows.map((r) => String(r[c.key] ?? '').length)));
|
||||
const sep = widths.map((w) => '-'.repeat(w)).join(' ');
|
||||
const fmtRow = (r) => columns.map((c, i) => String(r[c.key] ?? '').padEnd(widths[i])).join(' ');
|
||||
process.stdout.write(headers.map((h, i) => h.padEnd(widths[i])).join(' ') + '\n');
|
||||
process.stdout.write(sep + '\n');
|
||||
rows.forEach((r) => process.stdout.write(fmtRow(r) + '\n'));
|
||||
}
|
||||
exports.printTable = printTable;
|
||||
/** Print key: value pairs vertically. */
|
||||
function printKv(pairs) {
|
||||
const width = Math.max(...pairs.map(([k]) => k.length));
|
||||
pairs.forEach(([k, v]) => {
|
||||
const val = typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v ?? '');
|
||||
process.stdout.write(`${k.padEnd(width)} ${val}\n`);
|
||||
});
|
||||
}
|
||||
exports.printKv = printKv;
|
||||
/** Truncate a long string to width, appending ellipsis. */
|
||||
function truncate(s, width = 40) {
|
||||
const str = String(s ?? '');
|
||||
return str.length > width ? str.slice(0, width - 1) + '…' : str;
|
||||
}
|
||||
exports.truncate = truncate;
|
||||
Vendored
-124
@@ -1,124 +0,0 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.describeError = exports.request = exports.ApiError = void 0;
|
||||
/**
|
||||
* HTTP request helper using node:http / node:https.
|
||||
*
|
||||
* IMPORTANT: we deliberately avoid global fetch() on Node.js — the Node 24
|
||||
* undici fetch drops the Authorization header in some redirect/proxy scenarios
|
||||
* (confirmed bug in this project). Using the built-in http(s) module keeps the
|
||||
* Authorization: Bearer header intact end-to-end.
|
||||
*/
|
||||
const http = __importStar(require("node:http"));
|
||||
const https = __importStar(require("node:https"));
|
||||
class ApiError extends Error {
|
||||
status;
|
||||
body;
|
||||
constructor(status, message, body) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
exports.ApiError = ApiError;
|
||||
async function request(baseUrl, pathName, options = {}) {
|
||||
const url = new URL(pathName.startsWith('http') ? pathName : pathName.replace(/^\//, ''), baseUrl.endsWith('/') ? baseUrl : baseUrl + '/');
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const lib = isHttps ? https : http;
|
||||
const method = options.method || 'GET';
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
let bodyData;
|
||||
if (options.body !== undefined && options.body !== null) {
|
||||
bodyData = JSON.stringify(options.body);
|
||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
||||
headers['Content-Length'] = Buffer.byteLength(bodyData).toString();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = lib.request({
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method,
|
||||
headers,
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const buf = Buffer.concat(chunks);
|
||||
const text = buf.toString('utf8');
|
||||
let data = text;
|
||||
const ct = res.headers['content-type'] || '';
|
||||
if (ct.includes('application/json') || text.trim().startsWith('{') || text.trim().startsWith('[')) {
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
}
|
||||
catch {
|
||||
data = text;
|
||||
}
|
||||
}
|
||||
const status = res.statusCode || 200;
|
||||
if (status >= 200 && status < 300) {
|
||||
resolve({ status, headers: res.headers, data: data });
|
||||
}
|
||||
else {
|
||||
const errBody = data;
|
||||
const msg = (errBody && typeof errBody === 'object' && errBody.error?.message) ||
|
||||
(typeof errBody === 'object' && errBody.message) ||
|
||||
`HTTP ${status}`;
|
||||
reject(new ApiError(status, String(msg), errBody));
|
||||
}
|
||||
});
|
||||
});
|
||||
if (options.signal) {
|
||||
options.signal.addEventListener('abort', () => req.destroy(new Error('aborted')));
|
||||
}
|
||||
req.on('error', reject);
|
||||
if (bodyData !== undefined)
|
||||
req.write(bodyData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
exports.request = request;
|
||||
/** Extract `{ code, message }` from an ApiError body for display. */
|
||||
function describeError(err) {
|
||||
if (err instanceof ApiError) {
|
||||
const b = err.body;
|
||||
return {
|
||||
status: err.status,
|
||||
code: b?.error?.code || `HTTP_${err.status}`,
|
||||
message: b?.error?.message || err.message,
|
||||
};
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return { status: 0, code: 'ERROR', message: err.message };
|
||||
}
|
||||
return { status: 0, code: 'UNKNOWN', message: String(err) };
|
||||
}
|
||||
exports.describeError = describeError;
|
||||
+9
-1
@@ -1,9 +1,16 @@
|
||||
{
|
||||
"name": "@inkreach/teable-cli",
|
||||
"version": "0.1.0",
|
||||
"description": "CLI (command: inkreach-teable) for operating inkreach-teable: tables, fields, records, views, and the self-built automation engine (8 triggers + 6 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",
|
||||
"type": "commonjs",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "ssh://git@gitea.inkreach.cc:2222/inkreachhub/inkreach-teable-cli.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"bin": {
|
||||
"inkreach-teable": "./dist/bin.js"
|
||||
},
|
||||
@@ -12,6 +19,7 @@
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"dev": "tsc -p tsconfig.build.json --watch",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rimraf dist tsconfig.build.tsbuildinfo"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Generated
+1069
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -11,13 +11,14 @@ import { authCommand } from './commands/auth';
|
||||
import { workflowCommand, triggerCommand, actionCommand } from './commands/workflow';
|
||||
import { baseResourceCommands } from './commands/resources';
|
||||
import { secretCommand, runsCommand, fireCommand, sqlCommand } from './commands/extras';
|
||||
import { subscriptionCommand } from './commands/subscription';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('inkreach-teable')
|
||||
.description(
|
||||
'Operate inkreach-teable: tables, fields, records, views, and the self-built automation engine (8 triggers + 6 actions).\n' +
|
||||
'Operate inkreach-teable: tables, fields, records, views, and the self-built automation engine (8 triggers + 7 actions).\n' +
|
||||
'Use --json for machine-readable output (agent parsing).'
|
||||
)
|
||||
.version(`inkreach-teable ${version}`, '-v, --version');
|
||||
@@ -37,6 +38,7 @@ for (const c of baseResourceCommands()) program.addCommand(c);
|
||||
program.addCommand(workflowCommand());
|
||||
program.addCommand(triggerCommand());
|
||||
program.addCommand(actionCommand());
|
||||
program.addCommand(subscriptionCommand());
|
||||
program.addCommand(secretCommand());
|
||||
program.addCommand(runsCommand());
|
||||
program.addCommand(fireCommand());
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { presetConfigFromOptions } from './resources';
|
||||
|
||||
describe('presetConfigFromOptions', () => {
|
||||
it('parses provided JSON keys and drops absent ones', () => {
|
||||
const config = presetConfigFromOptions({
|
||||
tableId: 'tblX',
|
||||
name: 'n',
|
||||
filter: '{"conjunction":"and","filterSet":[]}',
|
||||
sort: '{"sortObjs":[{"fieldId":"fldA","order":"asc"}]}',
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
filter: { conjunction: 'and', filterSet: [] },
|
||||
sort: { sortObjs: [{ fieldId: 'fldA', order: 'asc' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty config when nothing is provided', () => {
|
||||
expect(presetConfigFromOptions({ tableId: 'tblX', name: 'n' })).toEqual({});
|
||||
});
|
||||
|
||||
it('throws a descriptive error on invalid JSON', () => {
|
||||
expect(() =>
|
||||
presetConfigFromOptions({ tableId: 'tblX', name: 'n', filter: '{oops' })
|
||||
).toThrowError(/Invalid JSON for --filter/);
|
||||
});
|
||||
});
|
||||
+298
-35
@@ -4,8 +4,9 @@
|
||||
* These endpoints are compatible with official teable open-api.
|
||||
*/
|
||||
import { Command } from 'commander';
|
||||
import { run, requireBaseId } from '../lib/ctx';
|
||||
import { run, requireBaseId, type Ctx } from '../lib/ctx';
|
||||
import { printJson, printTable, printKv, truncate } from '../lib/output';
|
||||
import { Client, ApiError } from '../lib/client';
|
||||
import {
|
||||
normalizeFieldRo,
|
||||
normalizeFieldRoArray,
|
||||
@@ -13,12 +14,43 @@ import {
|
||||
resolveFieldType,
|
||||
inferFieldType,
|
||||
findTableByNameOrId,
|
||||
findFieldByNameOrId,
|
||||
type ITableLike,
|
||||
type IFieldLike,
|
||||
} from '../lib/field-normalize';
|
||||
import { formatFieldDetail, summarizeFieldOptions, choiceRows } from '../lib/field-display';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* arg against the table's field list. Unresolvable input rethrows the
|
||||
* original error so the user sees the underlying cause.
|
||||
*/
|
||||
async function getFieldFlexible(
|
||||
client: Client,
|
||||
tableId: string,
|
||||
fieldIdOrName: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return (await client.getField(tableId, fieldIdOrName)) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.status === 400 || err.status === 404)) {
|
||||
const list = (await client.listFields(tableId)) as unknown as IFieldLike[];
|
||||
const hit = findFieldByNameOrId(list, fieldIdOrName);
|
||||
if (hit) return (await client.getField(tableId, hit.id)) as Record<string, unknown>;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** fieldId → display-name map of a table, to humanize visibilityFilter references. */
|
||||
async function fieldNameMap(client: Client, tableId: string): Promise<Record<string, string>> {
|
||||
const list = (await client.listFields(tableId)) as Array<{ id: string; name: string }>;
|
||||
return Object.fromEntries(list.map((f) => [f.id, f.name]));
|
||||
}
|
||||
|
||||
export function baseResourceCommands(): Command[] {
|
||||
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()];
|
||||
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd(), presetCmd()];
|
||||
}
|
||||
|
||||
// ===================== base =====================
|
||||
@@ -141,7 +173,11 @@ function tableCmd(): Command {
|
||||
const tables = await ctx.client.listTables(baseId).catch(() => []);
|
||||
for (const f of fields) {
|
||||
const options = f?.options as Record<string, unknown> | undefined;
|
||||
if (options && typeof options.foreignTableName === 'string' && !options.foreignTableId) {
|
||||
if (
|
||||
options &&
|
||||
typeof options.foreignTableName === 'string' &&
|
||||
!options.foreignTableId
|
||||
) {
|
||||
const t = findTableByNameOrId(
|
||||
(tables as unknown as ITableLike[]) || [],
|
||||
options.foreignTableName
|
||||
@@ -200,8 +236,13 @@ function fieldCmd(): Command {
|
||||
cmd
|
||||
.command('list <tableId>')
|
||||
.description('List fields in a table')
|
||||
.option(
|
||||
'--with-options',
|
||||
'Add an OPTIONS summary column and expand select choices with their visibility conditions'
|
||||
)
|
||||
.action(
|
||||
run(async (ctx, args) => {
|
||||
run(async (ctx, args, o) => {
|
||||
const opts = o as { withOptions?: boolean };
|
||||
const tableId = args[0] as string;
|
||||
const list = (await ctx.client.listFields(tableId)) as Array<Record<string, unknown>>;
|
||||
if (ctx.json) printJson(list);
|
||||
@@ -210,33 +251,73 @@ function fieldCmd(): Command {
|
||||
process.stdout.write('No fields found.\n');
|
||||
return;
|
||||
}
|
||||
printTable(
|
||||
[
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'type', header: 'TYPE' },
|
||||
],
|
||||
list.map((f) => ({
|
||||
id: truncate(f.id),
|
||||
name: truncate(f.name, 30),
|
||||
type: f.type,
|
||||
}))
|
||||
);
|
||||
if (opts.withOptions) {
|
||||
const nameMap = Object.fromEntries(list.map((f) => [String(f.id), String(f.name)]));
|
||||
printTable(
|
||||
[
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'type', header: 'TYPE' },
|
||||
{ key: 'options', header: 'OPTIONS' },
|
||||
],
|
||||
list.map((f) => ({
|
||||
id: truncate(f.id),
|
||||
name: truncate(f.name, 30),
|
||||
type: f.type,
|
||||
options: summarizeFieldOptions(f),
|
||||
}))
|
||||
);
|
||||
const selectFields = list.filter((f) =>
|
||||
Array.isArray((f.options as Record<string, unknown> | undefined)?.choices)
|
||||
);
|
||||
for (const f of selectFields) {
|
||||
const rows = choiceRows(f, nameMap);
|
||||
const conditional = rows.filter((r) => r.when !== '(always)').length;
|
||||
process.stdout.write(
|
||||
`\n◇ ${f.name} (${f.id}) — ${rows.length} choices · ${conditional} conditional\n`
|
||||
);
|
||||
for (const r of rows) {
|
||||
const color = r.color ? ` [${r.color}]` : '';
|
||||
process.stdout.write(` ${r.name}${color}\n`);
|
||||
if (r.when !== '(always)') {
|
||||
process.stdout.write(` when: ${r.when}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
printTable(
|
||||
[
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'type', header: 'TYPE' },
|
||||
],
|
||||
list.map((f) => ({
|
||||
id: truncate(f.id),
|
||||
name: truncate(f.name, 30),
|
||||
type: f.type,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
cmd
|
||||
.command('get <tableId> <fieldId>')
|
||||
.description('Get field details (full options incl. choice visibilityFilter)')
|
||||
.command('get <tableId> <fieldIdOrName>')
|
||||
.description(
|
||||
'Get field details by id or name (full options; select choices rendered with visibility conditions)'
|
||||
)
|
||||
.action(
|
||||
run(async (ctx, args) => {
|
||||
const [tableId, fieldId] = args as [string, string];
|
||||
const field = await ctx.client.getField(tableId, fieldId);
|
||||
const [tableId, fieldIdOrName] = args as [string, string];
|
||||
const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName);
|
||||
if (ctx.json) printJson(field);
|
||||
else {
|
||||
const f = field as Record<string, unknown>;
|
||||
printKv(Object.entries(f).map(([k, v]) => [k, v]));
|
||||
const choices = (field.options as Record<string, unknown> | undefined)?.choices;
|
||||
const names = Array.isArray(choices)
|
||||
? await fieldNameMap(ctx.client, tableId)
|
||||
: undefined;
|
||||
process.stdout.write(formatFieldDetail(field, names) + '\n');
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -250,9 +331,15 @@ function fieldCmd(): Command {
|
||||
'--type <type>',
|
||||
'Field type — full value (singleLineText, singleSelect, link…) or alias (text, sel, num…). Omitted: inferred from --options shape / --name'
|
||||
)
|
||||
.option('--options <json>', 'Field options JSON (type-specific; select choices accept plain strings)')
|
||||
.option(
|
||||
'--options <json>',
|
||||
'Field options JSON (type-specific; select choices accept plain strings)'
|
||||
)
|
||||
.option('--description <d>', 'Field description')
|
||||
.option('--lookup-options <json>', 'Lookup options JSON (names like linkFieldName/lookupFieldName are resolved to IDs)')
|
||||
.option(
|
||||
'--lookup-options <json>',
|
||||
'Lookup options JSON (names like linkFieldName/lookupFieldName are resolved to IDs)'
|
||||
)
|
||||
.option('--is-lookup', 'Mark the field as a lookup field')
|
||||
.action(
|
||||
run(async (ctx, _args, o) => {
|
||||
@@ -295,28 +382,32 @@ function fieldCmd(): Command {
|
||||
);
|
||||
|
||||
cmd
|
||||
.command('update <tableId> <fieldId>')
|
||||
.command('update <tableId> <fieldIdOrName>')
|
||||
.description(
|
||||
'Update a field. --name renames (PATCH); --options rewrites options via the convert API (the update API does not accept options)'
|
||||
'Update a field (by id or name). --name renames (PATCH); --options rewrites options via the convert API (the update API does not accept options)'
|
||||
)
|
||||
.option('--name <name>', 'New field name')
|
||||
.option('--options <json>', 'New field options JSON (written via convert API)')
|
||||
.action(
|
||||
run(async (ctx, args, o) => {
|
||||
const [tableId, fieldId] = args as [string, string];
|
||||
const [tableId, fieldIdOrName] = args as [string, string];
|
||||
const opts = o as { name?: string; options?: string };
|
||||
if (!opts.name && !opts.options) {
|
||||
throw new Error('Nothing to update: pass --name and/or --options');
|
||||
}
|
||||
if (opts.name && !opts.options) {
|
||||
const field = await ctx.client.updateField(tableId, fieldId, { name: opts.name });
|
||||
const current = (await getFieldFlexible(ctx.client, tableId, fieldIdOrName)) as {
|
||||
id: string;
|
||||
};
|
||||
const field = await ctx.client.updateField(tableId, current.id, { name: opts.name });
|
||||
if (ctx.json) printJson(field);
|
||||
else process.stdout.write(`✓ Field ${fieldId} renamed\n`);
|
||||
else process.stdout.write(`✓ Field ${current.id} renamed\n`);
|
||||
return;
|
||||
}
|
||||
// Options (choices / visibilityFilter / ...) must go through convert.
|
||||
// Fetch the current field to reuse its type; merge name if given.
|
||||
const current = (await ctx.client.getField(tableId, fieldId)) as {
|
||||
const current = (await getFieldFlexible(ctx.client, tableId, fieldIdOrName)) as {
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
const body: Record<string, unknown> = {
|
||||
@@ -325,21 +416,22 @@ function fieldCmd(): Command {
|
||||
};
|
||||
if (opts.name) body.name = opts.name;
|
||||
normalizeFieldRo(body);
|
||||
const field = await ctx.client.convertField(tableId, fieldId, body);
|
||||
const field = await ctx.client.convertField(tableId, current.id, body);
|
||||
if (ctx.json) printJson(field);
|
||||
else process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`);
|
||||
else process.stdout.write(`✓ Field ${current.id} options updated (via convert)\n`);
|
||||
})
|
||||
);
|
||||
|
||||
cmd
|
||||
.command('visible-choices <tableId> <recordId> <fieldId>')
|
||||
.command('visible-choices <tableId> <recordId> <fieldIdOrName>')
|
||||
.description(
|
||||
'Evaluate select-choice visibility for a record (respects choice visibilityFilter dependencies)'
|
||||
)
|
||||
.action(
|
||||
run(async (ctx, args) => {
|
||||
const [tableId, recordId, fieldId] = args as [string, string, string];
|
||||
const res = await ctx.client.getVisibleChoices(tableId, recordId, fieldId);
|
||||
const [tableId, recordId, fieldIdOrName] = args as [string, string, string];
|
||||
const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName);
|
||||
const res = await ctx.client.getVisibleChoices(tableId, recordId, String(field.id));
|
||||
if (ctx.json) printJson(res);
|
||||
else {
|
||||
printTable(
|
||||
@@ -634,3 +726,174 @@ function viewCmd(): Command {
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ===================== view preset =====================
|
||||
|
||||
/** Parse a --filter/--sort/... JSON option; empty/absent → undefined. */
|
||||
function parseJsonOption(value: string | undefined, optionName: string): unknown {
|
||||
if (value == null || value === '') return undefined;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid JSON for ${optionName}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface IPresetOptions {
|
||||
tableId: string;
|
||||
viewId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
filter?: string;
|
||||
sort?: string;
|
||||
group?: string;
|
||||
options?: string;
|
||||
columnMeta?: string;
|
||||
}
|
||||
|
||||
/** Collect preset config keys from options; absent keys stay out of the config. */
|
||||
export function presetConfigFromOptions(opts: IPresetOptions): Record<string, unknown> {
|
||||
const config: Record<string, unknown> = {};
|
||||
const entries: Array<[keyof IPresetOptions, string]> = [
|
||||
['filter', '--filter'],
|
||||
['sort', '--sort'],
|
||||
['group', '--group'],
|
||||
['options', '--options'],
|
||||
['columnMeta', '--column-meta'],
|
||||
];
|
||||
for (const [key, optionName] of entries) {
|
||||
const parsed = parseJsonOption(opts[key] as string | undefined, optionName);
|
||||
if (parsed !== undefined) config[key] = parsed;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function presetCmd(): Command {
|
||||
const cmd = new Command('preset');
|
||||
cmd.description(
|
||||
'Manage preset view configurations (named filter/sort/group bundles, shared with all collaborators)'
|
||||
);
|
||||
|
||||
cmd
|
||||
.command('list <tableId>')
|
||||
.description('List view presets of a table (pass --view-id to scope to one view)')
|
||||
.option('--view-id <id>', 'Only presets bound to this view')
|
||||
.action(
|
||||
run(async (ctx, args, o) => {
|
||||
const tableId = args[0] as string;
|
||||
const opts = o as unknown as IPresetOptions;
|
||||
const list = (await ctx.client.listViewPresets(tableId, opts.viewId)) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
if (ctx.json) printJson(list);
|
||||
else {
|
||||
if (!list.length) {
|
||||
process.stdout.write('No view presets found.\n');
|
||||
return;
|
||||
}
|
||||
printTable(
|
||||
[
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'config', header: 'CONFIG' },
|
||||
],
|
||||
list.map((p) => {
|
||||
const config = (p.config ?? {}) as Record<string, unknown>;
|
||||
const keys = Object.keys(config).filter((k) => config[k] != null);
|
||||
return {
|
||||
id: truncate(p.id),
|
||||
name: truncate(p.name, 30),
|
||||
config: keys.join(',') || '(empty)',
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
cmd
|
||||
.command('create')
|
||||
.description(
|
||||
'Create a view preset bound to one view (keys not passed stay unset — they keep the view values on apply)'
|
||||
)
|
||||
.requiredOption('--table-id <id>', 'Table id')
|
||||
.requiredOption('--view-id <id>', 'View id the preset belongs to')
|
||||
.requiredOption('--name <name>', 'Preset name')
|
||||
.option('--description <text>', 'Preset description')
|
||||
.option(
|
||||
'--filter <json>',
|
||||
'Filter config, e.g. \'{"conjunction":"and","filterSet":[{"fieldId":"fldX","operator":"is","value":"Pending"}]}\''
|
||||
)
|
||||
.option(
|
||||
'--sort <json>',
|
||||
'Sort config, e.g. \'{"sortObjs":[{"fieldId":"fldX","order":"asc"}]}\''
|
||||
)
|
||||
.option('--group <json>', 'Group config (JSON array of group items)')
|
||||
.option('--options <json>', 'View options (JSON object)')
|
||||
.option('--column-meta <json>', 'Column meta (field visibility/order, JSON object)')
|
||||
.action(
|
||||
run(async (ctx, _args, o) => {
|
||||
const opts = o as unknown as IPresetOptions;
|
||||
const body: Record<string, unknown> = {
|
||||
name: opts.name,
|
||||
viewId: opts.viewId,
|
||||
config: presetConfigFromOptions(opts),
|
||||
};
|
||||
if (opts.description) body.description = opts.description;
|
||||
const preset = (await ctx.client.createViewPreset(opts.tableId, body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (ctx.json) printJson(preset);
|
||||
else {
|
||||
printKv([
|
||||
['id', preset.id],
|
||||
['name', preset.name],
|
||||
['order', preset.order],
|
||||
]);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
cmd
|
||||
.command('update <presetId>')
|
||||
.description('Update a view preset (rename and/or replace config keys)')
|
||||
.requiredOption('--table-id <id>', 'Table id')
|
||||
.option('--name <name>', 'New preset name')
|
||||
.option('--description <text>', 'New preset description')
|
||||
.option('--filter <json>', 'Replace filter config (JSON)')
|
||||
.option('--sort <json>', 'Replace sort config (JSON)')
|
||||
.option('--group <json>', 'Replace group config (JSON)')
|
||||
.option('--options <json>', 'Replace view options (JSON object)')
|
||||
.option('--column-meta <json>', 'Replace column meta (JSON object)')
|
||||
.action(
|
||||
run(async (ctx, args, o) => {
|
||||
const presetId = args[0] as string;
|
||||
const opts = o as unknown as IPresetOptions;
|
||||
const body: Record<string, unknown> = {};
|
||||
if (opts.name) body.name = opts.name;
|
||||
if (opts.description !== undefined) body.description = opts.description;
|
||||
const config = presetConfigFromOptions(opts);
|
||||
if (Object.keys(config).length) body.config = config;
|
||||
await ctx.client.updateViewPreset(opts.tableId, presetId, body);
|
||||
if (ctx.json) printJson({ ok: true, id: presetId });
|
||||
else process.stdout.write(`✓ Preset ${presetId} updated\n`);
|
||||
})
|
||||
);
|
||||
|
||||
cmd
|
||||
.command('delete <presetId>')
|
||||
.description('Delete a view preset')
|
||||
.requiredOption('--table-id <id>', 'Table id')
|
||||
.action(
|
||||
run(async (ctx, args, o) => {
|
||||
const presetId = args[0] as string;
|
||||
const opts = o as { tableId: string };
|
||||
await ctx.client.deleteViewPreset(opts.tableId, presetId);
|
||||
if (ctx.json) printJson({ ok: true, id: presetId });
|
||||
else process.stdout.write(`✓ Preset ${presetId} deleted\n`);
|
||||
})
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* `inkreach-teable subscription` — 订阅中心全配置项 CRUD + 事件查看 + 立即发送。
|
||||
*
|
||||
* 覆盖订阅中心全部配置:name/table/watchCreated/watchUpdated/fieldIds/recordIds/
|
||||
* displayFieldId/attachCsv/filter(原生 IFilter JSON)/triggerOnEnter(进入条件语义)/
|
||||
* sendMode/recipients/notifyInApp/scheduleType/scheduleTime/scheduleWeekday/
|
||||
* scheduleInterval/isActive。
|
||||
*
|
||||
* filter 示例(与表格视图筛选同构):
|
||||
* --filter '{"conjunction":"and","filterSet":[{"fieldId":"fldX","operator":"isExactly","value":"已完成"}]}'
|
||||
* 日期筛选值结构:{"mode":"exactDate","exactDate":"2026-08-21","timeZone":"Asia/Shanghai"}
|
||||
* 用户筛选值:["usrXXX"]('Me' 由后端解析为订阅创建者)。
|
||||
*/
|
||||
import { Command } from 'commander';
|
||||
import { run, requireBaseId } from '../lib/ctx';
|
||||
import { printJson, printTable, printKv, truncate } from '../lib/output';
|
||||
import type {
|
||||
IRecordSubscriptionRo,
|
||||
IRecordSubscriptionVo,
|
||||
IRecordSubscriptionEventVo,
|
||||
} from '../lib/client';
|
||||
|
||||
function parseJsonFlag<T>(raw: string | undefined, flagName: string): T | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch (err) {
|
||||
throw new Error(`--${flagName} is not valid JSON: ${(err as Error).message}\n got: ${raw}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 'a@x.com,b@y.com' | '["a@x.com"]' → string[] */
|
||||
function parseRecipients(raw: string | undefined): string[] | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.startsWith('[')) {
|
||||
return parseJsonFlag<string[]>(raw, 'recipients');
|
||||
}
|
||||
return trimmed
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
interface SharedOpts {
|
||||
baseId?: string;
|
||||
name?: string;
|
||||
tableId?: string;
|
||||
watchCreated?: boolean;
|
||||
watchUpdated?: boolean;
|
||||
fieldIds?: string;
|
||||
recordIds?: string;
|
||||
displayFieldId?: string;
|
||||
attachCsv?: boolean;
|
||||
filter?: string;
|
||||
triggerOnEnter?: boolean;
|
||||
sendMode?: string;
|
||||
recipients?: string;
|
||||
notifyInApp?: boolean;
|
||||
scheduleType?: string;
|
||||
scheduleTime?: string;
|
||||
scheduleWeekday?: string;
|
||||
scheduleInterval?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/** 从 CLI 选项构建 RO body;create 时 name/tableId 必填。 */
|
||||
function buildRo(opts: SharedOpts, forCreate: boolean): Partial<IRecordSubscriptionRo> {
|
||||
const body: Partial<IRecordSubscriptionRo> = {};
|
||||
if (opts.name !== undefined) body.name = opts.name;
|
||||
if (opts.tableId !== undefined) body.tableId = opts.tableId;
|
||||
if (forCreate) {
|
||||
if (!body.name) throw new Error('--name is required for create');
|
||||
if (!body.tableId) throw new Error('--table-id is required for create');
|
||||
}
|
||||
if (opts.watchCreated !== undefined) body.watchCreated = opts.watchCreated;
|
||||
if (opts.watchUpdated !== undefined) body.watchUpdated = opts.watchUpdated;
|
||||
const fieldIds = parseJsonFlag<string[]>(opts.fieldIds, 'field-ids');
|
||||
if (fieldIds !== undefined) body.fieldIds = fieldIds;
|
||||
const recordIds = parseJsonFlag<string[]>(opts.recordIds, 'record-ids');
|
||||
if (recordIds !== undefined) body.recordIds = recordIds;
|
||||
if (opts.displayFieldId !== undefined) {
|
||||
body.displayFieldId = opts.displayFieldId === '' ? null : opts.displayFieldId;
|
||||
}
|
||||
if (opts.attachCsv !== undefined) body.attachCsv = opts.attachCsv;
|
||||
const filter = parseJsonFlag<Record<string, unknown>>(opts.filter, 'filter');
|
||||
if (filter !== undefined) body.filter = filter;
|
||||
if (opts.triggerOnEnter !== undefined) body.triggerOnEnter = opts.triggerOnEnter;
|
||||
if (opts.sendMode !== undefined) {
|
||||
if (opts.sendMode !== 'digest' && opts.sendMode !== 'instant') {
|
||||
throw new Error(`--send-mode must be digest|instant, got: ${opts.sendMode}`);
|
||||
}
|
||||
body.sendMode = opts.sendMode;
|
||||
}
|
||||
const recipients = parseRecipients(opts.recipients);
|
||||
if (recipients !== undefined) body.recipients = recipients;
|
||||
if (opts.notifyInApp !== undefined) body.notifyInApp = opts.notifyInApp;
|
||||
if (opts.scheduleType !== undefined) {
|
||||
const t = opts.scheduleType;
|
||||
if (!['daily', 'weekly', 'hours', 'minutes'].includes(t)) {
|
||||
throw new Error(`--schedule-type must be daily|weekly|hours|minutes, got: ${t}`);
|
||||
}
|
||||
body.scheduleType = t as IRecordSubscriptionRo['scheduleType'];
|
||||
}
|
||||
if (opts.scheduleTime !== undefined) body.scheduleTime = opts.scheduleTime;
|
||||
if (opts.scheduleWeekday !== undefined) {
|
||||
const n = Number(opts.scheduleWeekday);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 6) {
|
||||
throw new Error(`--schedule-weekday must be 0-6 (0=Sunday), got: ${opts.scheduleWeekday}`);
|
||||
}
|
||||
body.scheduleWeekday = n;
|
||||
}
|
||||
if (opts.scheduleInterval !== undefined) {
|
||||
const n = Number(opts.scheduleInterval);
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
throw new Error(
|
||||
`--schedule-interval must be a positive integer, got: ${opts.scheduleInterval}`
|
||||
);
|
||||
}
|
||||
body.scheduleInterval = n;
|
||||
}
|
||||
if (opts.isActive !== undefined) body.isActive = opts.isActive;
|
||||
return body;
|
||||
}
|
||||
|
||||
function addConfigOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.option('--name <name>', 'Subscription name')
|
||||
.option('--table-id <id>', 'Watched table id (tblXXX)')
|
||||
.option('--no-watch-created', 'Disable watching record creation (default: on)')
|
||||
.option('--no-watch-updated', 'Disable watching record updates (default: on)')
|
||||
.option(
|
||||
'--field-ids <json>',
|
||||
'Watched field ids as JSON array, e.g. \'["fldA","fldB"]\' (omit = all fields)'
|
||||
)
|
||||
.option(
|
||||
'--record-ids <json>',
|
||||
'Record-level subscription: tracked record ids as JSON array (omit = all records)'
|
||||
)
|
||||
.option('--display-field-id <id>', 'Field shown as record title in emails ("" = primary field)')
|
||||
.option('--attach-csv', 'Attach CSV of changes to digest emails (needs export permission)')
|
||||
.option('--no-attach-csv', 'Do not attach CSV')
|
||||
.option(
|
||||
'--filter <json>',
|
||||
'Native IFilter JSON (same shape as view filters); null/omit = all records'
|
||||
)
|
||||
.option(
|
||||
'--trigger-on-enter',
|
||||
'Only notify on the transition INTO the filter condition (requires --filter)'
|
||||
)
|
||||
.option('--no-trigger-on-enter', 'Notify on every matching change (state semantics)')
|
||||
.option('--send-mode <mode>', 'digest | instant')
|
||||
.option(
|
||||
'--recipients <list>',
|
||||
'Recipient emails: comma-separated or JSON array (default = creator)'
|
||||
)
|
||||
.option('--notify-in-app', 'Also send in-app notifications (default)')
|
||||
.option('--no-notify-in-app', 'Disable in-app notifications')
|
||||
.option('--schedule-type <type>', 'daily | weekly | hours | minutes')
|
||||
.option('--schedule-time <hh:mm>', 'Send time for daily/weekly, e.g. 09:00')
|
||||
.option('--schedule-weekday <n>', '0-6 (0=Sunday), for weekly')
|
||||
.option('--schedule-interval <n>', 'Interval for hours/minutes')
|
||||
.option('--active', 'Subscription active (default)')
|
||||
.option('--inactive', 'Pause the subscription');
|
||||
}
|
||||
|
||||
function sendModeBadge(s: IRecordSubscriptionVo): string {
|
||||
return s.sendMode === 'instant' ? 'instant' : 'digest';
|
||||
}
|
||||
|
||||
export function subscriptionCommand(): Command {
|
||||
const cmd = new Command('subscription');
|
||||
cmd.description('Manage the subscription center (record subscriptions) — full config support');
|
||||
|
||||
cmd
|
||||
.command('list')
|
||||
.description('List subscriptions in the base')
|
||||
.option('--table-id <id>', 'Filter by table')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action(
|
||||
run(async (ctx, _args, o) => {
|
||||
const opts = o as { tableId?: string; baseId?: string };
|
||||
const baseId = requireBaseId(ctx, opts.baseId);
|
||||
const list = await ctx.client.listSubscriptions(baseId, opts.tableId);
|
||||
if (ctx.json) printJson(list);
|
||||
else {
|
||||
if (!list.length) {
|
||||
process.stdout.write('No subscriptions found.\n');
|
||||
return;
|
||||
}
|
||||
printTable(
|
||||
[
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'tableId', header: 'TABLE' },
|
||||
{ key: 'mode', header: 'MODE' },
|
||||
{ key: 'onEnter', header: 'ON-ENTER' },
|
||||
{ key: 'schedule', header: 'SCHEDULE' },
|
||||
{ key: 'pending', header: 'PENDING' },
|
||||
{ key: 'active', header: 'ACTIVE' },
|
||||
],
|
||||
list.map((s) => ({
|
||||
id: s.id,
|
||||
name: truncate(s.name, 24),
|
||||
tableId: s.tableId,
|
||||
mode: sendModeBadge(s),
|
||||
onEnter: s.triggerOnEnter ? '✓' : '',
|
||||
schedule: s.sendMode === 'instant' ? '-' : s.scheduleType,
|
||||
pending: s.pendingCount ?? 0,
|
||||
active: s.isActive ? '✓' : '',
|
||||
}))
|
||||
);
|
||||
process.stdout.write(
|
||||
`\n${list.length} subscription(s). Use 'subscription get <id>' for details.\n`
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const getCmd = cmd.command('get <subscriptionId>').description('Show full subscription config');
|
||||
getCmd.option('--base-id <id>', 'Base id (overrides default)').action(
|
||||
run(async (ctx, args, o) => {
|
||||
const id = args[0] as string;
|
||||
const opts = o as { baseId?: string };
|
||||
const baseId = requireBaseId(ctx, opts.baseId);
|
||||
const list = await ctx.client.listSubscriptions(baseId);
|
||||
const sub = list.find((s) => s.id === id);
|
||||
if (!sub) throw new Error(`Subscription ${id} not found in base ${baseId}`);
|
||||
if (ctx.json) printJson(sub);
|
||||
else {
|
||||
printKv([
|
||||
['id', sub.id],
|
||||
['name', sub.name],
|
||||
['tableId', sub.tableId],
|
||||
['watchCreated', String(sub.watchCreated)],
|
||||
['watchUpdated', String(sub.watchUpdated)],
|
||||
['fieldIds', sub.fieldIds?.length ? sub.fieldIds.join(', ') : '(all fields)'],
|
||||
[
|
||||
'recordIds',
|
||||
sub.recordIds?.length ? `${sub.recordIds.length} record(s)` : '(all records)',
|
||||
],
|
||||
['displayFieldId', sub.displayFieldId ?? '(primary)'],
|
||||
['attachCsv', String(sub.attachCsv)],
|
||||
['filter', sub.filter ? JSON.stringify(sub.filter) : '(none)'],
|
||||
['triggerOnEnter', String(sub.triggerOnEnter)],
|
||||
['sendMode', sendModeBadge(sub)],
|
||||
['recipients', (sub.recipients ?? []).join(', ')],
|
||||
['notifyInApp', String(sub.notifyInApp)],
|
||||
['scheduleType', sub.scheduleType],
|
||||
['scheduleTime', sub.scheduleTime ?? ''],
|
||||
['scheduleWeekday', sub.scheduleWeekday != null ? String(sub.scheduleWeekday) : ''],
|
||||
['scheduleInterval', sub.scheduleInterval != null ? String(sub.scheduleInterval) : ''],
|
||||
['isActive', String(sub.isActive)],
|
||||
['pendingCount', String(sub.pendingCount ?? 0)],
|
||||
['lastSentTime', sub.lastSentTime ?? ''],
|
||||
]);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const createCmd = cmd
|
||||
.command('create')
|
||||
.description('Create a subscription (--name and --table-id required)');
|
||||
addConfigOptions(createCmd).action(
|
||||
run(async (ctx, _args, o) => {
|
||||
const opts = o as SharedOpts;
|
||||
const baseId = requireBaseId(ctx, opts.baseId);
|
||||
const body = buildRo(opts, true) as IRecordSubscriptionRo;
|
||||
const vo = await ctx.client.createSubscription(baseId, body);
|
||||
if (ctx.json) printJson(vo);
|
||||
else
|
||||
printKv([
|
||||
['id', vo.id],
|
||||
['name', vo.name],
|
||||
['created', '✓'],
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
const updateCmd = cmd
|
||||
.command('update <subscriptionId>')
|
||||
.description('Update a subscription (only provided options change)');
|
||||
addConfigOptions(updateCmd).action(
|
||||
run(async (ctx, args, o) => {
|
||||
const id = args[0] as string;
|
||||
const opts = o as SharedOpts;
|
||||
const baseId = requireBaseId(ctx, opts.baseId);
|
||||
const body = buildRo(opts, false);
|
||||
if (Object.keys(body).length === 0) {
|
||||
throw new Error('No options provided — nothing to update');
|
||||
}
|
||||
const vo = await ctx.client.updateSubscription(baseId, id, body);
|
||||
if (ctx.json) printJson(vo);
|
||||
else
|
||||
printKv([
|
||||
['id', vo.id],
|
||||
['updated', '✓'],
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
cmd
|
||||
.command('delete <subscriptionId>')
|
||||
.description('Delete a subscription')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action(
|
||||
run(async (ctx, args, o) => {
|
||||
const id = args[0] as string;
|
||||
const opts = o as { baseId?: string };
|
||||
const baseId = requireBaseId(ctx, opts.baseId);
|
||||
await ctx.client.deleteSubscription(baseId, id);
|
||||
if (ctx.json) printJson({ id });
|
||||
else process.stdout.write(`Deleted ${id}\n`);
|
||||
})
|
||||
);
|
||||
|
||||
cmd
|
||||
.command('events <subscriptionId>')
|
||||
.description('List captured events (pending + sent) of a subscription')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action(
|
||||
run(async (ctx, args, o) => {
|
||||
const id = args[0] as string;
|
||||
const opts = o as { baseId?: string };
|
||||
const baseId = requireBaseId(ctx, opts.baseId);
|
||||
const events = (await ctx.client.getSubscriptionEvents(
|
||||
baseId,
|
||||
id
|
||||
)) as IRecordSubscriptionEventVo[];
|
||||
if (ctx.json) printJson(events);
|
||||
else {
|
||||
if (!events.length) {
|
||||
process.stdout.write('No events captured.\n');
|
||||
return;
|
||||
}
|
||||
printTable(
|
||||
[
|
||||
{ key: 'recordId', header: 'RECORD' },
|
||||
{ key: 'changeType', header: 'CHANGE' },
|
||||
{ key: 'createdTime', header: 'CAPTURED' },
|
||||
{ key: 'sentTime', header: 'SENT' },
|
||||
],
|
||||
events.map((e) => ({
|
||||
recordId: e.recordId,
|
||||
changeType: e.changeType,
|
||||
createdTime: e.createdTime,
|
||||
sentTime: e.sentTime ?? '(pending)',
|
||||
}))
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
cmd
|
||||
.command('send <subscriptionId>')
|
||||
.description('Send pending digest events immediately (flush now)')
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action(
|
||||
run(async (ctx, args, o) => {
|
||||
const id = args[0] as string;
|
||||
const opts = o as { baseId?: string };
|
||||
const baseId = requireBaseId(ctx, opts.baseId);
|
||||
const result = await ctx.client.sendSubscriptionNow(baseId, id);
|
||||
if (ctx.json) printJson(result ?? { id });
|
||||
else
|
||||
printKv([
|
||||
['id', id],
|
||||
['sent', '✓'],
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
+94
-5
@@ -91,11 +91,7 @@ export class Client {
|
||||
/** PUT convert: the ONLY API path that persists field options (choices,
|
||||
* visibilityFilter dependency config, etc.). Requires the field's current type. */
|
||||
convertField(tableId: string, fieldId: string, body: unknown) {
|
||||
return this.call<unknown>(
|
||||
'PUT',
|
||||
`/api/table/${tableId}/field/${fieldId}/convert`,
|
||||
body
|
||||
);
|
||||
return this.call<unknown>('PUT', `/api/table/${tableId}/field/${fieldId}/convert`, body);
|
||||
}
|
||||
deleteField(tableId: string, fieldId: string) {
|
||||
return this.call<{ id: string }>('DELETE', `/api/table/${tableId}/field/${fieldId}`);
|
||||
@@ -170,6 +166,25 @@ export class Client {
|
||||
return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`);
|
||||
}
|
||||
|
||||
// ---- view preset (self-built contract) ----
|
||||
// Preset view configurations: VIEW-scoped named bundles of
|
||||
// filter/sort/group(/options/columnMeta) shared with all collaborators.
|
||||
listViewPresets(tableId: string, viewId?: string) {
|
||||
return this.call<unknown[]>(
|
||||
'GET',
|
||||
`/api/table/${tableId}/view-preset${viewId ? `?viewId=${encodeURIComponent(viewId)}` : ''}`
|
||||
);
|
||||
}
|
||||
createViewPreset(tableId: string, body: unknown) {
|
||||
return this.call<unknown>('POST', `/api/table/${tableId}/view-preset`, body);
|
||||
}
|
||||
updateViewPreset(tableId: string, presetId: string, body: unknown) {
|
||||
return this.call<unknown>('PUT', `/api/table/${tableId}/view-preset/${presetId}`, body);
|
||||
}
|
||||
deleteViewPreset(tableId: string, presetId: string) {
|
||||
return this.call<void>('DELETE', `/api/table/${tableId}/view-preset/${presetId}`);
|
||||
}
|
||||
|
||||
// ---- workflow (self-built contract) ----
|
||||
listWorkflows(baseId: string) {
|
||||
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow`);
|
||||
@@ -342,6 +357,38 @@ export class Client {
|
||||
removeDeveloper(userId: string) {
|
||||
return this.call<unknown>('DELETE', `/api/plugin-developers/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
// ---- subscription center (record subscriptions) ----
|
||||
listSubscriptions(baseId: string, tableId?: string) {
|
||||
const q = tableId ? `?tableId=${encodeURIComponent(tableId)}` : '';
|
||||
return this.call<IRecordSubscriptionVo[]>('GET', `/api/base/${baseId}/subscription${q}`);
|
||||
}
|
||||
getSubscriptionEvents(baseId: string, subscriptionId: string) {
|
||||
return this.call<IRecordSubscriptionEventVo[]>(
|
||||
'GET',
|
||||
`/api/base/${baseId}/subscription/${subscriptionId}/event`
|
||||
);
|
||||
}
|
||||
createSubscription(baseId: string, body: IRecordSubscriptionRo) {
|
||||
return this.call<IRecordSubscriptionVo>('POST', `/api/base/${baseId}/subscription`, body);
|
||||
}
|
||||
updateSubscription(baseId: string, subscriptionId: string, body: Partial<IRecordSubscriptionRo>) {
|
||||
return this.call<IRecordSubscriptionVo>(
|
||||
'PATCH',
|
||||
`/api/base/${baseId}/subscription/${subscriptionId}`,
|
||||
body
|
||||
);
|
||||
}
|
||||
deleteSubscription(baseId: string, subscriptionId: string) {
|
||||
return this.call<{ id: string }>(
|
||||
'DELETE',
|
||||
`/api/base/${baseId}/subscription/${subscriptionId}`
|
||||
);
|
||||
}
|
||||
/** 立即发送(flush pending digest events) */
|
||||
sendSubscriptionNow(baseId: string, subscriptionId: string) {
|
||||
return this.call<unknown>('POST', `/api/base/${baseId}/subscription/${subscriptionId}/send`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shared types (mirror backend workflow_node / workflow_edge shapes) ----
|
||||
@@ -398,3 +445,45 @@ export interface IWorkflowVo {
|
||||
}
|
||||
|
||||
export { ApiError };
|
||||
|
||||
// ---- subscription types (mirror backend record_subscription contract) ----
|
||||
export interface IRecordSubscriptionRo {
|
||||
name: string;
|
||||
tableId: string;
|
||||
watchCreated?: boolean;
|
||||
watchUpdated?: boolean;
|
||||
fieldIds?: string[];
|
||||
recordIds?: string[];
|
||||
displayFieldId?: string | null;
|
||||
attachCsv?: boolean;
|
||||
filter?: Record<string, unknown> | null;
|
||||
triggerOnEnter?: boolean;
|
||||
sendMode?: 'digest' | 'instant';
|
||||
recipients?: string[];
|
||||
notifyInApp?: boolean;
|
||||
scheduleType?: 'daily' | 'weekly' | 'hours' | 'minutes';
|
||||
scheduleTime?: string;
|
||||
scheduleWeekday?: number;
|
||||
scheduleInterval?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface IRecordSubscriptionVo extends IRecordSubscriptionRo {
|
||||
id: string;
|
||||
baseId: string;
|
||||
createdBy: string;
|
||||
createdTime: string;
|
||||
lastModifiedTime?: string | null;
|
||||
lastSentTime?: string | null;
|
||||
pendingCount?: number;
|
||||
}
|
||||
|
||||
export interface IRecordSubscriptionEventVo {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
recordId: string;
|
||||
changeType: 'created' | 'updated';
|
||||
changedFieldIds?: string[] | null;
|
||||
createdTime: string;
|
||||
sentTime?: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
summarizeFieldOptions,
|
||||
describeChoiceCondition,
|
||||
choiceRows,
|
||||
formatFieldDetail,
|
||||
} from './field-display';
|
||||
|
||||
describe('summarizeFieldOptions (field list --with-options column)', () => {
|
||||
it('counts select choices without conditions', () => {
|
||||
const field = {
|
||||
type: 'singleSelect',
|
||||
options: { choices: [{ name: 'A' }, { name: 'B' }, { name: 'C' }] },
|
||||
};
|
||||
expect(summarizeFieldOptions(field)).toBe('3 choices');
|
||||
});
|
||||
|
||||
it('counts conditional choices (visibilityFilter)', () => {
|
||||
const field = {
|
||||
type: 'singleSelect',
|
||||
options: {
|
||||
choices: [
|
||||
{ name: 'A' },
|
||||
{ name: 'B', visibilityFilter: { conjunction: 'and', filterSet: [] } },
|
||||
{ name: 'C', visibilityFilter: { conjunction: 'and', filterSet: [] } },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(summarizeFieldOptions(field)).toBe('3 choices · 2 conditional');
|
||||
});
|
||||
|
||||
it('summarizes rating options', () => {
|
||||
expect(summarizeFieldOptions({ type: 'rating', options: { max: 5, icon: 'star' } })).toBe(
|
||||
'max=5 icon=star'
|
||||
);
|
||||
});
|
||||
|
||||
it('summarizes number precision', () => {
|
||||
expect(
|
||||
summarizeFieldOptions({
|
||||
type: 'number',
|
||||
options: { formatting: { precision: 2 } },
|
||||
})
|
||||
).toBe('precision=2');
|
||||
});
|
||||
|
||||
it('summarizes date formatting', () => {
|
||||
expect(
|
||||
summarizeFieldOptions({
|
||||
type: 'date',
|
||||
options: { formatting: { date: 'YYYY-MM-DD', time: 'HH:mm' } },
|
||||
})
|
||||
).toBe('YYYY-MM-DD HH:mm');
|
||||
});
|
||||
|
||||
it('returns dash when no recognizable options', () => {
|
||||
expect(summarizeFieldOptions({ type: 'singleLineText' })).toBe('—');
|
||||
expect(summarizeFieldOptions({ type: 'checkbox', options: {} })).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeChoiceCondition (visibilityFilter summary)', () => {
|
||||
it('returns (always) when no filter', () => {
|
||||
expect(describeChoiceCondition(undefined)).toBe('(always)');
|
||||
expect(describeChoiceCondition(null)).toBe('(always)');
|
||||
});
|
||||
|
||||
it('renders a single is rule with resolved field name', () => {
|
||||
const vf = {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: 'fldA', operator: 'is', value: '物流问题-丢件' }],
|
||||
};
|
||||
expect(describeChoiceCondition(vf, { fldA: '问题类型' })).toBe('问题类型 is 物流问题-丢件');
|
||||
});
|
||||
|
||||
it('falls back to fieldId when no name map', () => {
|
||||
const vf = {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: 'fldA', operator: 'is', value: 'X' }],
|
||||
};
|
||||
expect(describeChoiceCondition(vf)).toBe('fldA is X');
|
||||
});
|
||||
|
||||
it('joins rules with AND / OR and array values with |', () => {
|
||||
const vfOr = {
|
||||
conjunction: 'or',
|
||||
filterSet: [
|
||||
{ fieldId: 'fldA', operator: 'is', value: ['A1', 'A2'] },
|
||||
{ fieldId: 'fldA', operator: 'is', value: 'B' },
|
||||
],
|
||||
};
|
||||
expect(describeChoiceCondition(vfOr, { fldA: '问题类型' })).toBe(
|
||||
'问题类型 is A1|A2 OR 问题类型 is B'
|
||||
);
|
||||
const vfAnd = {
|
||||
conjunction: 'and',
|
||||
filterSet: [
|
||||
{ fieldId: 'fldA', operator: 'isNot', value: 'X' },
|
||||
{ fieldId: 'fldB', operator: 'isNotEmpty', value: null },
|
||||
],
|
||||
};
|
||||
expect(describeChoiceCondition(vfAnd, { fldA: '甲', fldB: '乙' })).toBe(
|
||||
'甲 isNot X AND 乙 isNotEmpty'
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates long rule lists with +N more', () => {
|
||||
const vf = {
|
||||
conjunction: 'or',
|
||||
filterSet: Array.from({ length: 6 }, (_, i) => ({
|
||||
fieldId: 'fldA',
|
||||
operator: 'is',
|
||||
value: `value-number-${i}`,
|
||||
})),
|
||||
};
|
||||
const out = describeChoiceCondition(vf, { fldA: 'F' });
|
||||
// 4 rules fit the budget → the marker must report exactly the 2 omitted.
|
||||
expect(out).toMatch(/\+2 more$/);
|
||||
expect(out).not.toContain('value-number-4');
|
||||
expect(out).not.toContain('value-number-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('choiceRows (select field → display rows)', () => {
|
||||
it('maps choices to name/color/when rows', () => {
|
||||
const field = {
|
||||
type: 'singleSelect',
|
||||
options: {
|
||||
choices: [
|
||||
{ name: 'A', color: 'red' },
|
||||
{
|
||||
name: 'B',
|
||||
visibilityFilter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: 'fldX', operator: 'is', value: 'V' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const rows = choiceRows(field, { fldX: 'X' });
|
||||
expect(rows).toEqual([
|
||||
{ name: 'A', color: 'red', when: '(always)' },
|
||||
{ name: 'B', color: '', when: 'X is V' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] for non-select fields', () => {
|
||||
expect(choiceRows({ type: 'number', options: {} })).toEqual([]);
|
||||
expect(choiceRows({ type: 'singleSelect', options: {} })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFieldDetail (field get human mode)', () => {
|
||||
it('expands select choices into lines with conditions', () => {
|
||||
const field = {
|
||||
id: 'fld1',
|
||||
name: '处理反馈',
|
||||
type: 'singleSelect',
|
||||
unique: false,
|
||||
options: {
|
||||
defaultValue: 'A',
|
||||
choices: [
|
||||
{ name: 'A', color: 'blue' },
|
||||
{
|
||||
name: 'B',
|
||||
visibilityFilter: {
|
||||
conjunction: 'and',
|
||||
filterSet: [{ fieldId: 'fldT', operator: 'is', value: '丢件' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const out = formatFieldDetail(field, { fldT: '问题类型' });
|
||||
expect(out).toContain('处理反馈');
|
||||
expect(out).toMatch(/choices \(2 · 1 conditional\)/);
|
||||
expect(out).toContain('(always)');
|
||||
expect(out).toContain('问题类型 is 丢件');
|
||||
expect(out).toContain('defaultValue'); // non-choice options still visible
|
||||
});
|
||||
|
||||
it('keeps non-select options as a JSON line', () => {
|
||||
const field = {
|
||||
id: 'fld2',
|
||||
name: 'Score',
|
||||
type: 'rating',
|
||||
options: { max: 5, icon: 'star' },
|
||||
};
|
||||
const out = formatFieldDetail(field);
|
||||
expect(out).toContain('max');
|
||||
expect(out).toContain('"max":5');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Human-mode display helpers for field options — make select-choice
|
||||
* constraints (choices, colors, per-choice visibilityFilter) readable
|
||||
* without `--json` post-processing.
|
||||
*/
|
||||
|
||||
const CONDITION_MAX_LEN = 90;
|
||||
|
||||
/** Summarize a field's options for the `field list --with-options` column. */
|
||||
export function summarizeFieldOptions(field: Record<string, unknown>): string {
|
||||
const options = field.options as Record<string, unknown> | undefined;
|
||||
const choices = options?.choices;
|
||||
if (Array.isArray(choices) && choices.length) {
|
||||
const conditional = choices.filter(
|
||||
(c) => c && typeof c === 'object' && (c as { visibilityFilter?: unknown }).visibilityFilter
|
||||
).length;
|
||||
return conditional
|
||||
? `${choices.length} choices · ${conditional} conditional`
|
||||
: `${choices.length} choices`;
|
||||
}
|
||||
if (!options || typeof options !== 'object') return '—';
|
||||
const type = field.type as string | undefined;
|
||||
if (type === 'rating') {
|
||||
return [
|
||||
`max=${options.max ?? 5}`,
|
||||
typeof options.icon === 'string' ? `icon=${options.icon}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
const formatting = options.formatting as Record<string, unknown> | undefined;
|
||||
if (type === 'number' && formatting) {
|
||||
return `precision=${formatting.precision ?? 0}`;
|
||||
}
|
||||
if ((type === 'date' || type === 'createdTime' || type === 'lastModifiedTime') && formatting) {
|
||||
return [formatting.date, formatting.time && formatting.time !== 'None' ? formatting.time : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
if (typeof options.expression === 'string') {
|
||||
const e = options.expression;
|
||||
return `expr: ${e.length > 40 ? e.slice(0, 39) + '…' : e}`;
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
|
||||
interface IFilterItem {
|
||||
fieldId?: string;
|
||||
operator?: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (Array.isArray(value)) return value.map((v) => String(v)).join('|');
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** One-line human summary of a choice's visibilityFilter; `(always)` when absent. */
|
||||
export function describeChoiceCondition(vf: unknown, fieldNames?: Record<string, string>): string {
|
||||
if (!vf || typeof vf !== 'object') return '(always)';
|
||||
const { conjunction, filterSet } = vf as { conjunction?: string; filterSet?: IFilterItem[] };
|
||||
if (!Array.isArray(filterSet) || !filterSet.length) return '(always)';
|
||||
const joiner = conjunction === 'or' ? ' OR ' : ' AND ';
|
||||
const rules = filterSet.map(({ fieldId, operator, value }) => {
|
||||
const label = (fieldId && fieldNames?.[fieldId]) || fieldId || '?';
|
||||
const v = formatValue(value);
|
||||
return v ? `${label} ${operator ?? '?'} ${v}` : `${label} ${operator ?? '?'}`;
|
||||
});
|
||||
let text = '';
|
||||
let kept = 0;
|
||||
for (const rule of rules) {
|
||||
const candidate = text ? text + joiner + rule : rule;
|
||||
if (candidate.length > CONDITION_MAX_LEN && kept > 0) break;
|
||||
text = candidate;
|
||||
kept++;
|
||||
}
|
||||
const omitted = rules.length - kept;
|
||||
return omitted > 0 ? `${text}… +${omitted} more` : text;
|
||||
}
|
||||
|
||||
export interface IChoiceRow {
|
||||
name: string;
|
||||
color: string;
|
||||
when: string;
|
||||
}
|
||||
|
||||
/** Choices of a select field as display rows; `[]` when the field has none. */
|
||||
export function choiceRows(
|
||||
field: Record<string, unknown>,
|
||||
fieldNames?: Record<string, string>
|
||||
): IChoiceRow[] {
|
||||
const choices = (field.options as Record<string, unknown> | undefined)?.choices;
|
||||
if (!Array.isArray(choices)) return [];
|
||||
return choices
|
||||
.filter((c): c is Record<string, unknown> => !!c && typeof c === 'object')
|
||||
.map((c) => ({
|
||||
name: String(c.name ?? ''),
|
||||
color: typeof c.color === 'string' ? c.color : '',
|
||||
when: describeChoiceCondition(c.visibilityFilter, fieldNames),
|
||||
}));
|
||||
}
|
||||
|
||||
function kvLine(key: string, value: unknown, keyWidth: number): string {
|
||||
const val =
|
||||
typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value ?? '');
|
||||
return `${key.padEnd(keyWidth)} ${val}`;
|
||||
}
|
||||
|
||||
/** Full human-mode rendering for `field get`: kv lines, with select choices
|
||||
* expanded into a per-choice condition listing instead of one JSON blob. */
|
||||
export function formatFieldDetail(
|
||||
field: Record<string, unknown>,
|
||||
fieldNames?: Record<string, string>
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
const options = field.options as Record<string, unknown> | undefined;
|
||||
const choices = options?.choices;
|
||||
const restEntries = Object.entries({ ...field });
|
||||
const keyWidth = Math.max(...restEntries.map(([k]) => k.length), 'defaultValue'.length);
|
||||
|
||||
for (const [k, v] of restEntries) {
|
||||
if (k === 'options') continue;
|
||||
lines.push(kvLine(k, v, keyWidth));
|
||||
}
|
||||
|
||||
if (Array.isArray(choices) && choices.length) {
|
||||
const conditional = choices.filter(
|
||||
(c) => c && typeof c === 'object' && (c as { visibilityFilter?: unknown }).visibilityFilter
|
||||
).length;
|
||||
lines.push('');
|
||||
lines.push(`choices (${choices.length} · ${conditional} conditional):`);
|
||||
for (const row of choiceRows(field, fieldNames)) {
|
||||
const color = row.color ? ` [${row.color}]` : '';
|
||||
lines.push(` ${row.name}${color}`);
|
||||
lines.push(` when: ${row.when}`);
|
||||
}
|
||||
const otherOpts = Object.fromEntries(
|
||||
Object.entries(options ?? {}).filter(([k]) => k !== 'choices')
|
||||
);
|
||||
if (Object.keys(otherOpts).length) {
|
||||
lines.push('');
|
||||
lines.push(kvLine('options (other)', otherOpts, keyWidth));
|
||||
}
|
||||
} else if (options && Object.keys(options).length) {
|
||||
lines.push(kvLine('options', options, keyWidth));
|
||||
} else {
|
||||
lines.push(kvLine('options', undefined, keyWidth));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
+9
-3
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2022",
|
||||
@@ -12,8 +11,15 @@
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"allowJs": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"isolatedModules": true,
|
||||
"incremental": true,
|
||||
"newLine": "lf"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user