@sqaoss/flowy 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -33
- package/package.json +1 -1
- package/skills/using-flowy/SKILL.md +127 -55
- package/src/commands/key.test.ts +45 -7
- package/src/commands/key.ts +14 -4
- package/src/commands/setup.test.ts +137 -11
- package/src/commands/setup.ts +45 -17
- package/src/commands/whoami.test.ts +31 -2
- package/src/commands/whoami.ts +8 -2
- package/src/util/config.test.ts +50 -2
- package/src/util/config.ts +37 -4
package/README.md
CHANGED
|
@@ -14,40 +14,107 @@ You get full observability on what every agent planned, built, and shipped.
|
|
|
14
14
|
|
|
15
15
|
## Get Started
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Flowy runs in one of two modes. Pick the one that fits:
|
|
18
|
+
|
|
19
|
+
- **Self-hosted** — a local server you run yourself (`flowy serve`). No account, no subscription, your data stays on your machine. Start here if you just want to try Flowy.
|
|
20
|
+
- **Remote (hosted)** — the managed service at `flowy-ai.fly.dev`. Register with an email, then subscribe at checkout. The hosted server gates data operations behind an active subscription.
|
|
21
|
+
|
|
22
|
+
### Quickstart (self-hosted, no account)
|
|
18
23
|
|
|
19
24
|
```bash
|
|
20
25
|
npm i -g @sqaoss/flowy
|
|
21
|
-
flowy setup
|
|
26
|
+
flowy setup local # installs the bundled server, points the CLI at localhost
|
|
27
|
+
flowy serve & # starts the local server on 127.0.0.1:4000
|
|
28
|
+
|
|
29
|
+
cd my-project
|
|
30
|
+
flowy init # auto-detects the git repo, creates + maps a project
|
|
31
|
+
|
|
32
|
+
flowy feature create --title "User Auth" --description "Email + OAuth login"
|
|
33
|
+
flowy feature set "User Auth"
|
|
34
|
+
|
|
35
|
+
flowy task create --title "Implement OAuth" --description "Wire up the OAuth provider"
|
|
36
|
+
flowy status <task-id> in_progress
|
|
37
|
+
flowy status <task-id> done
|
|
22
38
|
```
|
|
23
39
|
|
|
24
|
-
|
|
40
|
+
`flowy serve` runs in the foreground; the `&` backgrounds it. Stop it with `kill %1` or run it in a separate terminal. Data lives in `./flowy.sqlite`.
|
|
41
|
+
|
|
42
|
+
### Quickstart (remote/hosted)
|
|
25
43
|
|
|
26
44
|
```bash
|
|
45
|
+
npm i -g @sqaoss/flowy
|
|
46
|
+
flowy setup remote --email you@example.com # registers; prints an apiKey + checkoutUrl
|
|
47
|
+
|
|
27
48
|
cd my-project
|
|
28
|
-
flowy init
|
|
49
|
+
flowy init # auto-detects the git repo, creates + maps a project
|
|
50
|
+
flowy task create --title "First task" --description "Try it out"
|
|
29
51
|
```
|
|
30
52
|
|
|
31
|
-
|
|
53
|
+
`setup remote` registers your email and stores the returned API key. It prints a `checkoutUrl` — **open it to start a subscription**. Until you do, the hosted server may reject data operations with `An active subscription is required`. You no longer need to choose a tier up front (`--tier` is optional); pick one at checkout.
|
|
54
|
+
|
|
55
|
+
Every command outputs JSON. Your agent reads it, acts on it, moves to the next task.
|
|
56
|
+
|
|
57
|
+
### Descriptions: literal vs. file
|
|
58
|
+
|
|
59
|
+
`--description` is **always literal text** — it is never read as a file path. To load a description from a file (or stdin), use `--description-file`:
|
|
32
60
|
|
|
33
61
|
```bash
|
|
34
|
-
flowy
|
|
35
|
-
flowy feature
|
|
62
|
+
flowy task create --title "Write tests" --description "Unit + integration tests"
|
|
63
|
+
flowy feature create --title "User Auth" --description-file auth-spec.md
|
|
64
|
+
flowy task create --title "From stdin" --description-file - # reads stdin
|
|
65
|
+
```
|
|
36
66
|
|
|
37
|
-
|
|
38
|
-
flowy task create --title "Write tests" --description "Unit + integration"
|
|
67
|
+
### Dependencies and ready work
|
|
39
68
|
|
|
40
|
-
|
|
41
|
-
|
|
69
|
+
Tasks can block one another. Mark a dependency, inspect it, and ask for only the tasks that are actually actionable right now:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
flowy task block <blocker-id> <blocked-id> # blocker must finish before blocked
|
|
73
|
+
flowy task deps <id> # what blocks this task, and what it blocks
|
|
74
|
+
flowy task show <id> # task details, now including blockedBy/blocks
|
|
75
|
+
|
|
76
|
+
flowy task list --ready # only unblocked, not-done tasks (active project)
|
|
77
|
+
flowy task list --ready --project <project-id> # ...scoped to a specific project
|
|
78
|
+
flowy task list --all # every task across the whole backlog
|
|
42
79
|
```
|
|
43
80
|
|
|
44
|
-
|
|
81
|
+
`--ready` returns tasks that are not `done`/`cancelled` and have zero unfinished blockers — the work an agent can pick up next.
|
|
82
|
+
|
|
83
|
+
### Import and export
|
|
84
|
+
|
|
85
|
+
Move a whole backlog in or out as a single JSON manifest. Import is **idempotent**: each node carries a stable `key` (a client-key), so re-importing updates the matching nodes in place instead of duplicating them. Edges (`part_of`, `blocks`) round-trip through the real edge model, so a `block` you created by hand is captured on export and not re-created on the next import.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
flowy export # print the active project's manifest to stdout
|
|
89
|
+
flowy export backlog.json # ...or write it to a file
|
|
90
|
+
flowy import backlog.json # ingest a manifest (create new, update existing by key)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A manifest looks like:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"version": 1,
|
|
98
|
+
"nodes": [
|
|
99
|
+
{ "key": "proj", "type": "project", "title": "My Project" },
|
|
100
|
+
{ "key": "auth", "type": "feature", "title": "User Auth", "parent": "proj" },
|
|
101
|
+
{ "key": "oauth", "type": "task", "title": "Implement OAuth", "parent": "auth", "status": "draft" }
|
|
102
|
+
],
|
|
103
|
+
"edges": [
|
|
104
|
+
{ "source": "oauth", "target": "auth", "relation": "part_of" }
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Each node's `parent` implies a `part_of` edge, so the simplest manifests need no explicit `edges`. `blocks` dependencies go in `edges`. The reserved `__flowyKey` metadata field stores the client-key; your own `metadata` is preserved alongside it and stripped back out on export.
|
|
45
110
|
|
|
46
111
|
## Agent Skill
|
|
47
112
|
|
|
48
|
-
|
|
113
|
+
`flowy setup` installs an agent skill so your AI agent automatically knows every command. If that install step fails (offline, no `npx`, registry hiccup), setup prints a warning telling you to install it manually:
|
|
49
114
|
|
|
50
|
-
|
|
115
|
+
```bash
|
|
116
|
+
npx skills add sqaoss/flowy
|
|
117
|
+
```
|
|
51
118
|
|
|
52
119
|
See [skills/using-flowy/SKILL.md](skills/using-flowy/SKILL.md) for the full skill reference.
|
|
53
120
|
|
|
@@ -70,50 +137,65 @@ Also: `blocked`, `cancelled`. Only `pending_review` entities can be approved.
|
|
|
70
137
|
|
|
71
138
|
## Self-Hosted
|
|
72
139
|
|
|
73
|
-
Run Flowy on your own machine
|
|
140
|
+
Run Flowy on your own machine — no Docker, no account, no subscription. `flowy setup local` installs a bundled server pinned to your CLI version and points the CLI at `localhost`; `flowy serve` runs it natively over SQLite.
|
|
74
141
|
|
|
75
142
|
```bash
|
|
76
|
-
flowy setup local
|
|
77
|
-
flowy
|
|
143
|
+
flowy setup local # install the bundled server, configure the CLI
|
|
144
|
+
flowy serve # bind 127.0.0.1:4000, store data in ./flowy.sqlite
|
|
145
|
+
flowy serve --port 5000 --host 0.0.0.0 --db ~/flowy.sqlite # override defaults
|
|
78
146
|
```
|
|
79
147
|
|
|
148
|
+
The self-hosted server supports the full planning workflow — `init`, `project`/`feature`/`task` CRUD, `status`, `approve`, `search`, `tree`, `task deps`, `task list --ready/--all`, and `import`/`export`. Account-only commands (`whoami`, `billing`, `key`) are remote-mode features and don't apply locally.
|
|
149
|
+
|
|
80
150
|
## Command Reference
|
|
81
151
|
|
|
82
152
|
| Command | Description |
|
|
83
153
|
|---------|-------------|
|
|
84
|
-
| `setup
|
|
85
|
-
| `setup
|
|
154
|
+
| `setup local` | Install the bundled local server and point the CLI at it |
|
|
155
|
+
| `setup remote --email <email> [--tier <tier>]` | Register with the hosted server (`--tier` optional) |
|
|
156
|
+
| `serve [--port] [--host] [--db]` | Run the bundled local server (self-hosted mode) |
|
|
86
157
|
| `init` | Auto-detect repo and create/map project |
|
|
87
|
-
| `whoami` | Show current user |
|
|
88
158
|
| `client set name <name>` | Set client display name |
|
|
89
159
|
| `project create <name>` | Create project |
|
|
90
|
-
| `project set <name>` | Map current directory to project |
|
|
160
|
+
| `project set <name>` | Map current directory to a project |
|
|
91
161
|
| `project list` | List all projects |
|
|
92
|
-
| `project show [<id>]` | Show project details |
|
|
93
|
-
| `
|
|
162
|
+
| `project show [<id>]` | Show project details (defaults to active) |
|
|
163
|
+
| `project update [<id>] [--title] [--description\|--description-file] [--metadata]` | Update a project |
|
|
164
|
+
| `project delete [<id>]` | Delete a project (defaults to active) |
|
|
165
|
+
| `feature create --title <t> [--description <text>\|--description-file <path>]` | Create feature (requires active project) |
|
|
94
166
|
| `feature set <name-or-id>` | Set active feature |
|
|
95
167
|
| `feature unset` | Clear active feature |
|
|
96
168
|
| `feature list` | List features in active project |
|
|
97
|
-
| `feature show [<id>]` | Show feature details |
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `task
|
|
101
|
-
| `task
|
|
102
|
-
| `task
|
|
169
|
+
| `feature show [<id>]` | Show feature details (defaults to active) |
|
|
170
|
+
| `feature update [<id>] [--title] [--description\|--description-file] [--metadata]` | Update a feature |
|
|
171
|
+
| `feature delete [<id>]` | Delete a feature (defaults to active) |
|
|
172
|
+
| `task create --title <t> [--description <text>\|--description-file <path>]` | Create task (requires active feature) |
|
|
173
|
+
| `task list [--ready] [--all] [--project <id>]` | List tasks: active feature, or `--ready`/`--all` (optionally scoped to a project) |
|
|
174
|
+
| `task show <id>` | Show task details, including `blockedBy`/`blocks` |
|
|
175
|
+
| `task update <id> [--title] [--description\|--description-file] [--metadata]` | Update a task |
|
|
176
|
+
| `task delete <id>` | Delete a task |
|
|
177
|
+
| `task block <id1> <id2>` | Mark `id1` as blocking `id2` |
|
|
178
|
+
| `task unblock <id1> <id2>` | Remove a blocking relationship |
|
|
179
|
+
| `task deps <id>` | Show what blocks a task and what it blocks |
|
|
103
180
|
| `status <id> <status>` | Update status (shorthand) |
|
|
104
181
|
| `approve <id>` | Approve (must be pending_review) |
|
|
105
182
|
| `search <query> [--type] [--status] [--limit]` | Full-text search |
|
|
106
|
-
| `tree <id> [--depth N]` | Show subtree |
|
|
183
|
+
| `tree <id> [--depth N]` | Show subtree from any entity |
|
|
184
|
+
| `import <manifest>` | Ingest a JSON manifest of nodes + edges (idempotent by client-key) |
|
|
185
|
+
| `export [output]` | Dump the active project as a manifest (stdout or file) |
|
|
186
|
+
| `whoami` | Show current user (remote mode) |
|
|
187
|
+
| `billing checkout --tier <tier>` | Get a checkout URL for a subscription (remote mode) |
|
|
188
|
+
| `key rotate` | Revoke all API keys and issue a new one (remote mode) |
|
|
107
189
|
|
|
108
|
-
All commands output JSON to stdout
|
|
190
|
+
All commands output JSON to stdout; errors go to stderr as `{ "error": "message" }`.
|
|
109
191
|
|
|
110
192
|
## Configuration
|
|
111
193
|
|
|
112
|
-
Config is stored at `~/.config/flowy/config.json`.
|
|
194
|
+
Config is stored at `~/.config/flowy/config.json`. These environment variables override config:
|
|
113
195
|
|
|
114
196
|
| Variable | Description | Default |
|
|
115
197
|
|----------|-------------|---------|
|
|
116
|
-
| `FLOWY_API_URL` | GraphQL endpoint | `https://flowy-ai.fly.dev/graphql` |
|
|
198
|
+
| `FLOWY_API_URL` | GraphQL endpoint | `https://flowy-ai.fly.dev/graphql` (remote) / `http://localhost:4000/graphql` (local) |
|
|
117
199
|
| `FLOWY_API_KEY` | API key (remote mode) | -- |
|
|
118
200
|
| `FLOWY_PROJECT` | Override active project by name | -- |
|
|
119
201
|
| `FLOWY_FEATURE` | Override active feature by ID | -- |
|
package/package.json
CHANGED
|
@@ -11,30 +11,54 @@ Flowy gives you a persistent store for plans and execution tracking. Features ar
|
|
|
11
11
|
|
|
12
12
|
Without Flowy, your plans live in markdown files that clutter git history, get deleted when done, and leave no record of what you accomplished. With Flowy, plans persist in a database. You flow through work without friction. Your human gets full observability.
|
|
13
13
|
|
|
14
|
+
## Two Modes
|
|
15
|
+
|
|
16
|
+
Flowy runs against one of two backends. Which one you're in determines which commands work — check `~/.config/flowy/config.json` (`mode` is `local` or `remote`).
|
|
17
|
+
|
|
18
|
+
| | Local (self-hosted) | Remote (hosted SaaS) |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| Setup | `flowy setup local` then `flowy serve` | `flowy setup remote --email <email>` |
|
|
21
|
+
| Server | A bundled server you run (`flowy serve`, SQLite, `127.0.0.1:4000`) | `flowy-ai.fly.dev` |
|
|
22
|
+
| Account / API key | None | Email registration; API key stored in config |
|
|
23
|
+
| Subscription | None — fully free | Data operations require an active subscription |
|
|
24
|
+
| Planning workflow | Full (`init`, project/feature/task CRUD, status, approve, search, tree, `task deps`, `task list --ready/--all`, `import`/`export`) | Full, same commands |
|
|
25
|
+
| `whoami` / `billing` / `key` | **Not available** — these hard-fail locally | Available |
|
|
26
|
+
|
|
27
|
+
The planning workflow is **identical** in both modes. Only the account/billing commands differ.
|
|
28
|
+
|
|
14
29
|
## First Time in a Project
|
|
15
30
|
|
|
16
31
|
```bash
|
|
17
32
|
flowy init # auto-detects the git repo, creates a project, maps this directory
|
|
18
33
|
```
|
|
19
34
|
|
|
20
|
-
If Flowy isn't set up yet, the human needs to
|
|
35
|
+
If Flowy isn't set up yet, the human needs to choose a mode:
|
|
36
|
+
|
|
21
37
|
```bash
|
|
22
38
|
npm i -g @sqaoss/flowy
|
|
23
|
-
|
|
39
|
+
|
|
40
|
+
# Self-hosted (free, no account):
|
|
41
|
+
flowy setup local # installs the bundled server, points the CLI at localhost
|
|
42
|
+
flowy serve # starts the local server on 127.0.0.1:4000 (run in its own terminal)
|
|
43
|
+
|
|
44
|
+
# OR hosted (managed service):
|
|
45
|
+
flowy setup remote --email their@email.com # registers; prints apiKey + checkoutUrl
|
|
24
46
|
```
|
|
25
47
|
|
|
26
|
-
|
|
48
|
+
`setup remote` prints a `checkoutUrl`. On the hosted server, data operations are rejected until a subscription is active — the human opens that URL to subscribe. `--tier` is optional at registration; a tier is chosen at checkout.
|
|
49
|
+
|
|
50
|
+
`flowy setup` also installs this agent skill via `npx skills add sqaoss/flowy`. If that step fails, setup prints a warning with the manual install command — the skill is not installed until you run it.
|
|
27
51
|
|
|
28
52
|
## Core Workflow
|
|
29
53
|
|
|
30
54
|
```bash
|
|
31
55
|
# 1. Plan a feature (master plan)
|
|
32
|
-
flowy feature create --title "User Auth" --description
|
|
56
|
+
flowy feature create --title "User Auth" --description "Email + OAuth login"
|
|
33
57
|
flowy feature set "User Auth"
|
|
34
58
|
|
|
35
59
|
# 2. Break into tasks (execution steps)
|
|
36
|
-
flowy task create --title "Implement OAuth" --description
|
|
37
|
-
flowy task create --title "Write tests"
|
|
60
|
+
flowy task create --title "Implement OAuth" --description "Wire up the OAuth provider"
|
|
61
|
+
flowy task create --title "Write tests" --description-file tests-plan.md
|
|
38
62
|
|
|
39
63
|
# 3. Execute and track
|
|
40
64
|
flowy status <task-id> in_progress
|
|
@@ -42,31 +66,19 @@ flowy status <task-id> in_progress
|
|
|
42
66
|
flowy status <task-id> done
|
|
43
67
|
|
|
44
68
|
# 4. Move to next task or feature
|
|
45
|
-
flowy feature create --title "API Rate Limiting" --description rate-limit.md
|
|
69
|
+
flowy feature create --title "API Rate Limiting" --description-file rate-limit.md
|
|
46
70
|
flowy feature set "API Rate Limiting"
|
|
47
71
|
```
|
|
48
72
|
|
|
49
73
|
## Entity Hierarchy
|
|
50
74
|
|
|
51
75
|
```
|
|
52
|
-
project -> feature -> task
|
|
53
|
-
|
|
76
|
+
client -> project -> feature -> task
|
|
77
|
+
1:many 1:many
|
|
54
78
|
```
|
|
55
79
|
|
|
56
80
|
Every task belongs to a feature. Every feature belongs to a project. No orphans. The project is set automatically by `flowy init`.
|
|
57
81
|
|
|
58
|
-
## Subscription Tiers
|
|
59
|
-
|
|
60
|
-
Flowy requires an active subscription for all data operations.
|
|
61
|
-
|
|
62
|
-
| Tier | Projects | Description |
|
|
63
|
-
|------|----------|-------------|
|
|
64
|
-
| `explorer` | Up to 10 | For individual developers |
|
|
65
|
-
| `pro` | Unlimited | For power users |
|
|
66
|
-
| `team` | Unlimited | For teams |
|
|
67
|
-
|
|
68
|
-
After registration, complete payment at the checkout URL. Existing users can get a new checkout URL with `flowy billing checkout --tier <tier>`.
|
|
69
|
-
|
|
70
82
|
## Status Flow
|
|
71
83
|
|
|
72
84
|
```
|
|
@@ -75,85 +87,145 @@ draft -> pending_review -> approved -> in_progress -> done
|
|
|
75
87
|
|
|
76
88
|
Also: `blocked`, `cancelled`
|
|
77
89
|
|
|
78
|
-
Only `pending_review`
|
|
90
|
+
Use `flowy status <id> <status>` to move a node. Only `pending_review` nodes can be approved via `flowy approve <id>`.
|
|
79
91
|
|
|
80
92
|
## Commands
|
|
81
93
|
|
|
94
|
+
### Setup and Server
|
|
95
|
+
```bash
|
|
96
|
+
flowy setup local # install bundled local server, configure CLI
|
|
97
|
+
flowy serve # run the local server (127.0.0.1:4000, ./flowy.sqlite)
|
|
98
|
+
flowy serve --port 5000 --host 0.0.0.0 --db ~/flowy.sqlite
|
|
99
|
+
flowy setup remote --email <email> # register with the hosted server (--tier optional)
|
|
100
|
+
flowy setup remote --email <email> --tier explorer
|
|
101
|
+
```
|
|
102
|
+
|
|
82
103
|
### Project Context
|
|
83
104
|
```bash
|
|
84
|
-
flowy init
|
|
85
|
-
flowy project
|
|
86
|
-
flowy project
|
|
105
|
+
flowy init # auto-detect repo, create + map project
|
|
106
|
+
flowy project create <name> # create a project by name
|
|
107
|
+
flowy project set <name> # map current directory to an existing project
|
|
108
|
+
flowy project list # list all projects
|
|
109
|
+
flowy project show [<id>] # show project details (defaults to active)
|
|
110
|
+
flowy project update [<id>] --title <t> # update title/description/metadata
|
|
111
|
+
flowy project delete [<id>] # delete project (defaults to active)
|
|
87
112
|
```
|
|
88
113
|
|
|
89
114
|
### Features (requires active project)
|
|
90
115
|
```bash
|
|
91
|
-
flowy feature create --title "Title" --description "
|
|
92
|
-
flowy feature
|
|
93
|
-
flowy feature
|
|
94
|
-
flowy feature
|
|
95
|
-
flowy feature
|
|
116
|
+
flowy feature create --title "Title" --description "text"
|
|
117
|
+
flowy feature create --title "Title" --description-file spec.md
|
|
118
|
+
flowy feature set "Title or ID" # set active feature
|
|
119
|
+
flowy feature unset # clear active feature
|
|
120
|
+
flowy feature list # list features in active project
|
|
121
|
+
flowy feature show [<id>] # show feature (defaults to active)
|
|
122
|
+
flowy feature update [<id>] --title <t> # update title/description/metadata
|
|
123
|
+
flowy feature delete [<id>] # delete feature (defaults to active)
|
|
96
124
|
```
|
|
97
125
|
|
|
98
126
|
### Tasks (requires active feature)
|
|
99
127
|
```bash
|
|
100
|
-
flowy task create --title "Title" --description "
|
|
101
|
-
flowy task
|
|
102
|
-
flowy task
|
|
103
|
-
flowy task
|
|
104
|
-
flowy task
|
|
128
|
+
flowy task create --title "Title" --description "text"
|
|
129
|
+
flowy task create --title "Title" --description-file spec.md
|
|
130
|
+
flowy task list # tasks in active feature
|
|
131
|
+
flowy task list --ready # only actionable tasks (active project)
|
|
132
|
+
flowy task list --ready --project <id> # ...scoped to a specific project
|
|
133
|
+
flowy task list --all # every task across the whole backlog
|
|
134
|
+
flowy task show <id> # task details, incl. blockedBy/blocks
|
|
135
|
+
flowy task update <id> --title <t> # update title/description/metadata
|
|
136
|
+
flowy task delete <id> # delete task
|
|
137
|
+
flowy task block <id1> <id2> # mark id1 as blocking id2
|
|
138
|
+
flowy task unblock <id1> <id2> # remove a blocking relationship
|
|
139
|
+
flowy task deps <id> # what blocks this task, and what it blocks
|
|
105
140
|
```
|
|
106
141
|
|
|
142
|
+
`task list --ready` returns only tasks that are not `done`/`cancelled` and have zero unfinished blockers — the next work an agent can pick up. Without `--project` it scopes to the active project; with `--all` it spans the whole backlog. `task deps <id>` (and the `blockedBy`/`blocks` fields on `task show`) report the dependency graph built from `task block`.
|
|
143
|
+
|
|
107
144
|
### Status and Approval
|
|
108
145
|
```bash
|
|
109
146
|
flowy status <id> in_progress
|
|
110
147
|
flowy status <id> pending_review
|
|
111
|
-
flowy approve <id>
|
|
148
|
+
flowy approve <id> # only works on pending_review
|
|
112
149
|
flowy status <id> done
|
|
113
150
|
```
|
|
114
151
|
|
|
115
152
|
### Search and Explore
|
|
116
153
|
```bash
|
|
117
154
|
flowy search "query" --type task --status draft --limit 10
|
|
118
|
-
flowy tree <
|
|
155
|
+
flowy tree <id> --depth 3 # show subtree from any entity
|
|
119
156
|
```
|
|
120
157
|
|
|
121
|
-
###
|
|
158
|
+
### Import and Export
|
|
122
159
|
```bash
|
|
123
|
-
flowy
|
|
124
|
-
flowy
|
|
125
|
-
flowy
|
|
160
|
+
flowy export # print active project's manifest to stdout
|
|
161
|
+
flowy export backlog.json # ...or write it to a file
|
|
162
|
+
flowy import backlog.json # ingest a manifest (idempotent by client-key)
|
|
126
163
|
```
|
|
127
164
|
|
|
128
|
-
|
|
165
|
+
A manifest is a single JSON document describing a backlog: `nodes` (projects, features, tasks) plus dependency `edges`. Each node is addressed by a stable **client-key** (`key`), not a server id:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"version": 1,
|
|
170
|
+
"nodes": [
|
|
171
|
+
{ "key": "proj", "type": "project", "title": "My Project" },
|
|
172
|
+
{ "key": "auth", "type": "feature", "title": "User Auth", "parent": "proj" },
|
|
173
|
+
{ "key": "oauth", "type": "task", "title": "Implement OAuth", "parent": "auth", "status": "draft" }
|
|
174
|
+
],
|
|
175
|
+
"edges": [
|
|
176
|
+
{ "source": "oauth", "target": "auth", "relation": "part_of" }
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Idempotency:** import upserts by client-key — re-importing the same manifest updates the matching nodes in place instead of creating duplicates. The key is stored in node metadata under the reserved `__flowyKey` field (your own `metadata` is preserved alongside it and stripped back out on export). A node's `parent` implies a `part_of` edge, so simple manifests need no explicit `edges`; `blocks` dependencies are listed in `edges`. Edges live in the real edge model (`createEdge` / `Query.edges`), so a `blocks` edge created by hand with `task block` is captured on export and never re-created on the next import. Works in both local and remote modes.
|
|
182
|
+
|
|
183
|
+
### Remote-only (hosted mode)
|
|
184
|
+
These hit account/billing resolvers that do **not** exist on the local server; they fail in local mode.
|
|
129
185
|
```bash
|
|
130
|
-
flowy
|
|
186
|
+
flowy whoami # show current user (id, email, tier, graceEndsAt)
|
|
187
|
+
flowy billing checkout --tier <tier> # get a checkout URL (tier: explorer, pro, team)
|
|
188
|
+
flowy key rotate # revoke all API keys and issue a new one
|
|
131
189
|
```
|
|
132
190
|
|
|
133
|
-
###
|
|
191
|
+
### Client Settings (local config)
|
|
134
192
|
```bash
|
|
135
|
-
flowy
|
|
193
|
+
flowy client set name "Your Name" # set client display name in local config
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Descriptions: literal vs. file
|
|
197
|
+
|
|
198
|
+
`create` and `update` commands take a description two ways. They are mutually exclusive.
|
|
199
|
+
|
|
200
|
+
- `--description <text>` — **always literal**. The text is used verbatim and is *never* interpreted as a file path. `--description plan.md` stores the string `plan.md`, not the file's contents.
|
|
201
|
+
- `--description-file <path>` — reads the file's contents as the description. Use `-` to read from stdin.
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
flowy task create --title "T" --description "Do the thing"
|
|
205
|
+
flowy task create --title "T" --description-file plan.md
|
|
206
|
+
flowy task create --title "T" --description-file - # from stdin
|
|
136
207
|
```
|
|
137
208
|
|
|
138
209
|
## Validation Rules
|
|
139
210
|
|
|
140
|
-
- **Title is required**
|
|
141
|
-
- **Description** is
|
|
142
|
-
-
|
|
143
|
-
- **
|
|
144
|
-
- **
|
|
145
|
-
- **
|
|
146
|
-
- **Edges**: both source and target nodes must exist
|
|
211
|
+
- **Title is required** on every `create` (cannot be empty).
|
|
212
|
+
- **Description is required** on `create`: pass exactly one of `--description` or `--description-file`. Passing both is an error; passing neither is an error.
|
|
213
|
+
- **Search** requires a non-empty query and returns up to `--limit` results (default 50).
|
|
214
|
+
- **Status** must be one of: `draft`, `pending_review`, `approved`, `in_progress`, `done`, `blocked`, `cancelled`.
|
|
215
|
+
- **Blocking** creates a `blocks` edge between two existing tasks; both nodes must exist.
|
|
216
|
+
- **Approve** only succeeds on a node currently in `pending_review`.
|
|
147
217
|
|
|
148
218
|
## Output Format
|
|
149
219
|
|
|
150
|
-
All commands output JSON to stdout. Errors go to stderr as `{ "error": "message" }
|
|
220
|
+
All commands output JSON to stdout. Errors go to stderr as `{ "error": "message" }` (with an optional `code`), and the process exits non-zero.
|
|
221
|
+
|
|
222
|
+
On the hosted server, an expired or missing subscription surfaces as an error like `An active subscription is required. Run \`flowy billing checkout\` to subscribe.` This never happens in local mode.
|
|
151
223
|
|
|
152
224
|
## Environment Variables
|
|
153
225
|
|
|
154
226
|
| Variable | Description |
|
|
155
227
|
|----------|-------------|
|
|
228
|
+
| `FLOWY_API_URL` | GraphQL endpoint (defaults: hosted in remote mode, `http://localhost:4000/graphql` in local mode) |
|
|
229
|
+
| `FLOWY_API_KEY` | API key (remote mode; set by `flowy setup remote`) |
|
|
156
230
|
| `FLOWY_PROJECT` | Override active project by name |
|
|
157
231
|
| `FLOWY_FEATURE` | Override active feature by ID |
|
|
158
|
-
| `FLOWY_API_URL` | GraphQL endpoint |
|
|
159
|
-
| `FLOWY_API_KEY` | API key (from setup) |
|
package/src/commands/key.test.ts
CHANGED
|
@@ -17,10 +17,17 @@ beforeEach(() => {
|
|
|
17
17
|
mockOutput = vi.fn()
|
|
18
18
|
mockOutputError = vi.fn()
|
|
19
19
|
|
|
20
|
-
vi.doMock('../util/config.ts', () =>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
vi.doMock('../util/config.ts', async () => {
|
|
21
|
+
const actual =
|
|
22
|
+
await vi.importActual<typeof import('../util/config.ts')>(
|
|
23
|
+
'../util/config.ts',
|
|
24
|
+
)
|
|
25
|
+
return {
|
|
26
|
+
loadConfig: mockLoadConfig,
|
|
27
|
+
saveConfig: mockSaveConfig,
|
|
28
|
+
fingerprintKey: actual.fingerprintKey,
|
|
29
|
+
}
|
|
30
|
+
})
|
|
24
31
|
|
|
25
32
|
vi.doMock('../util/format.ts', () => ({
|
|
26
33
|
output: mockOutput,
|
|
@@ -46,7 +53,7 @@ describe('key command', () => {
|
|
|
46
53
|
expect(keyCommand.commands).toHaveLength(1)
|
|
47
54
|
})
|
|
48
55
|
|
|
49
|
-
test('rotate calls rotateApiKey mutation, saves new key to config, and outputs
|
|
56
|
+
test('rotate calls rotateApiKey mutation, saves new key to config, and outputs a fingerprint (not the secret)', async () => {
|
|
50
57
|
const mockGraphql = vi.fn().mockResolvedValue({
|
|
51
58
|
rotateApiKey: {
|
|
52
59
|
user: {
|
|
@@ -74,12 +81,43 @@ describe('key command', () => {
|
|
|
74
81
|
apiKey: 'flowy_new_key_456',
|
|
75
82
|
}),
|
|
76
83
|
)
|
|
77
|
-
|
|
84
|
+
|
|
85
|
+
// Default output must NOT leak the full secret.
|
|
86
|
+
const outputArg = mockOutput.mock.calls[0]![0]
|
|
87
|
+
expect(JSON.stringify(outputArg)).not.toContain('flowy_new_key_456')
|
|
88
|
+
expect(outputArg).toEqual(
|
|
78
89
|
expect.objectContaining({
|
|
79
90
|
user: expect.objectContaining({ email: 'test@example.com' }),
|
|
80
|
-
|
|
91
|
+
keyFingerprint: expect.stringMatching(/sha256:[0-9a-f]{12}/),
|
|
81
92
|
}),
|
|
82
93
|
)
|
|
94
|
+
expect(outputArg).not.toHaveProperty('apiKey')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('rotate --show-key reveals the full secret', async () => {
|
|
98
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
99
|
+
rotateApiKey: {
|
|
100
|
+
user: {
|
|
101
|
+
id: 'user_1',
|
|
102
|
+
email: 'test@example.com',
|
|
103
|
+
tier: 'free',
|
|
104
|
+
createdAt: '2025-01-01T00:00:00Z',
|
|
105
|
+
graceEndsAt: null,
|
|
106
|
+
},
|
|
107
|
+
apiKey: 'flowy_new_key_456',
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
vi.doMock('../util/client.ts', () => ({
|
|
111
|
+
graphql: mockGraphql,
|
|
112
|
+
}))
|
|
113
|
+
|
|
114
|
+
const { keyCommand } = await import('./key.ts')
|
|
115
|
+
await keyCommand.parseAsync(['rotate', '--show-key'], { from: 'user' })
|
|
116
|
+
|
|
117
|
+
const outputArg = mockOutput.mock.calls[0]![0]
|
|
118
|
+
expect(outputArg).toEqual(
|
|
119
|
+
expect.objectContaining({ apiKey: 'flowy_new_key_456' }),
|
|
120
|
+
)
|
|
83
121
|
})
|
|
84
122
|
|
|
85
123
|
test('rotate saves the exact new apiKey to config', async () => {
|
package/src/commands/key.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
|
-
import { loadConfig, saveConfig } from '../util/config.ts'
|
|
3
|
+
import { fingerprintKey, loadConfig, saveConfig } from '../util/config.ts'
|
|
4
4
|
import { output, outputError } from '../util/format.ts'
|
|
5
5
|
|
|
6
6
|
export const keyCommand = new Command('key').description('API key management')
|
|
@@ -8,7 +8,11 @@ export const keyCommand = new Command('key').description('API key management')
|
|
|
8
8
|
keyCommand
|
|
9
9
|
.command('rotate')
|
|
10
10
|
.description('Rotate API key')
|
|
11
|
-
.
|
|
11
|
+
.option(
|
|
12
|
+
'--show-key',
|
|
13
|
+
'Print the full API key instead of a non-reversible fingerprint',
|
|
14
|
+
)
|
|
15
|
+
.action(async (opts) => {
|
|
12
16
|
try {
|
|
13
17
|
const data = await graphql<{
|
|
14
18
|
rotateApiKey: {
|
|
@@ -30,11 +34,17 @@ keyCommand
|
|
|
30
34
|
}`,
|
|
31
35
|
)
|
|
32
36
|
|
|
37
|
+
const { user, apiKey } = data.rotateApiKey
|
|
33
38
|
const config = loadConfig()
|
|
34
|
-
config.apiKey =
|
|
39
|
+
config.apiKey = apiKey
|
|
35
40
|
saveConfig(config)
|
|
36
41
|
|
|
37
|
-
output(
|
|
42
|
+
// Default output never leaks the secret; --show-key opts in (F35).
|
|
43
|
+
output(
|
|
44
|
+
opts.showKey
|
|
45
|
+
? { user, apiKey }
|
|
46
|
+
: { user, keyFingerprint: fingerprintKey(apiKey) },
|
|
47
|
+
)
|
|
38
48
|
} catch (error) {
|
|
39
49
|
outputError(error)
|
|
40
50
|
}
|
|
@@ -19,10 +19,17 @@ beforeEach(() => {
|
|
|
19
19
|
mockOutputError = vi.fn()
|
|
20
20
|
mockSpawnSync = vi.fn()
|
|
21
21
|
|
|
22
|
-
vi.doMock('../util/config.ts', () =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
vi.doMock('../util/config.ts', async () => {
|
|
23
|
+
const actual =
|
|
24
|
+
await vi.importActual<typeof import('../util/config.ts')>(
|
|
25
|
+
'../util/config.ts',
|
|
26
|
+
)
|
|
27
|
+
return {
|
|
28
|
+
loadConfig: mockLoadConfig,
|
|
29
|
+
saveConfig: mockSaveConfig,
|
|
30
|
+
fingerprintKey: actual.fingerprintKey,
|
|
31
|
+
}
|
|
32
|
+
})
|
|
26
33
|
|
|
27
34
|
vi.doMock('../util/format.ts', () => ({
|
|
28
35
|
output: mockOutput,
|
|
@@ -97,16 +104,37 @@ describe('setup command', () => {
|
|
|
97
104
|
)
|
|
98
105
|
})
|
|
99
106
|
|
|
100
|
-
test('setup remote
|
|
107
|
+
test('setup remote registers without --tier (tier is optional)', async () => {
|
|
108
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
109
|
+
register: {
|
|
110
|
+
user: {
|
|
111
|
+
id: 'user_1',
|
|
112
|
+
email: 'test@example.com',
|
|
113
|
+
tier: null,
|
|
114
|
+
createdAt: '2026-06-13T00:00:00Z',
|
|
115
|
+
graceEndsAt: null,
|
|
116
|
+
},
|
|
117
|
+
apiKey: 'flowy_key',
|
|
118
|
+
checkoutUrl: null,
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
vi.doMock('../util/client.ts', () => ({
|
|
122
|
+
graphql: mockGraphql,
|
|
123
|
+
}))
|
|
124
|
+
mockSpawnSync.mockReturnValue({ status: 0, stdout: Buffer.from('') })
|
|
125
|
+
|
|
101
126
|
const { setupCommand } = await import('./setup.ts')
|
|
102
127
|
await setupCommand.parseAsync(['remote', '--email', 'test@example.com'], {
|
|
103
128
|
from: 'user',
|
|
104
129
|
})
|
|
105
130
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
131
|
+
// No tier required: registration proceeds, no usage error raised.
|
|
132
|
+
expect(mockOutputError).not.toHaveBeenCalled()
|
|
133
|
+
expect(mockGraphql).toHaveBeenCalledOnce()
|
|
134
|
+
const [, variables] = mockGraphql.mock.calls[0]!
|
|
135
|
+
expect(variables).toEqual({ email: 'test@example.com', tier: undefined })
|
|
136
|
+
expect(mockSaveConfig).toHaveBeenCalledWith(
|
|
137
|
+
expect.objectContaining({ mode: 'remote', apiKey: 'flowy_key' }),
|
|
110
138
|
)
|
|
111
139
|
})
|
|
112
140
|
|
|
@@ -209,16 +237,114 @@ describe('setup command', () => {
|
|
|
209
237
|
apiKey: 'flowy_test_key_123',
|
|
210
238
|
}),
|
|
211
239
|
)
|
|
212
|
-
|
|
240
|
+
// Default output surfaces a fingerprint, never the raw secret (F35).
|
|
241
|
+
const outputArg = mockOutput.mock.calls[0]![0]
|
|
242
|
+
expect(JSON.stringify(outputArg)).not.toContain('flowy_test_key_123')
|
|
243
|
+
expect(outputArg).toEqual(
|
|
213
244
|
expect.objectContaining({
|
|
214
245
|
user: expect.objectContaining({
|
|
215
246
|
email: 'test@example.com',
|
|
216
247
|
tier: 'explorer',
|
|
217
248
|
graceEndsAt: '2026-04-13T00:00:00Z',
|
|
218
249
|
}),
|
|
219
|
-
|
|
250
|
+
keyFingerprint: expect.stringMatching(/sha256:[0-9a-f]{12}/),
|
|
220
251
|
checkoutUrl: 'https://checkout.stripe.com/session_123',
|
|
221
252
|
}),
|
|
222
253
|
)
|
|
254
|
+
expect(outputArg).not.toHaveProperty('apiKey')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('setup remote --show-key reveals the full API key', async () => {
|
|
258
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
259
|
+
register: {
|
|
260
|
+
user: {
|
|
261
|
+
id: 'user_1',
|
|
262
|
+
email: 'test@example.com',
|
|
263
|
+
tier: 'explorer',
|
|
264
|
+
createdAt: '2026-03-30T00:00:00Z',
|
|
265
|
+
graceEndsAt: null,
|
|
266
|
+
},
|
|
267
|
+
apiKey: 'flowy_test_key_123',
|
|
268
|
+
checkoutUrl: null,
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
vi.doMock('../util/client.ts', () => ({
|
|
272
|
+
graphql: mockGraphql,
|
|
273
|
+
}))
|
|
274
|
+
mockSpawnSync.mockReturnValue({ status: 0, stdout: Buffer.from('') })
|
|
275
|
+
|
|
276
|
+
const { setupCommand } = await import('./setup.ts')
|
|
277
|
+
await setupCommand.parseAsync(
|
|
278
|
+
['remote', '--email', 'test@example.com', '--show-key'],
|
|
279
|
+
{ from: 'user' },
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
expect(mockOutput).toHaveBeenCalledWith(
|
|
283
|
+
expect.objectContaining({ apiKey: 'flowy_test_key_123' }),
|
|
284
|
+
)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('setup local warns when the skill install fails', async () => {
|
|
288
|
+
// bun add succeeds, npx skills add fails (non-zero status).
|
|
289
|
+
mockSpawnSync.mockImplementation((cmd: string) =>
|
|
290
|
+
cmd === 'npx' ? { status: 1 } : { status: 0 },
|
|
291
|
+
)
|
|
292
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
293
|
+
|
|
294
|
+
const { setupCommand } = await import('./setup.ts')
|
|
295
|
+
await setupCommand.parseAsync(['local'], { from: 'user' })
|
|
296
|
+
|
|
297
|
+
const warned = errSpy.mock.calls.map((c) => String(c[0])).join('\n')
|
|
298
|
+
expect(warned).toMatch(/skill/i)
|
|
299
|
+
expect(warned).toContain('npx skills add sqaoss/flowy')
|
|
300
|
+
// Setup itself still completes successfully (config saved, result emitted).
|
|
301
|
+
expect(mockOutput).toHaveBeenCalled()
|
|
302
|
+
expect(mockOutputError).not.toHaveBeenCalled()
|
|
303
|
+
errSpy.mockRestore()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('setup local does not warn when the skill install succeeds', async () => {
|
|
307
|
+
mockSpawnSync.mockReturnValue({ status: 0 })
|
|
308
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
309
|
+
|
|
310
|
+
const { setupCommand } = await import('./setup.ts')
|
|
311
|
+
await setupCommand.parseAsync(['local'], { from: 'user' })
|
|
312
|
+
|
|
313
|
+
const warned = errSpy.mock.calls.map((c) => String(c[0])).join('\n')
|
|
314
|
+
expect(warned).not.toMatch(/skill/i)
|
|
315
|
+
errSpy.mockRestore()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('setup remote warns when the skill install fails', async () => {
|
|
319
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
320
|
+
register: {
|
|
321
|
+
user: {
|
|
322
|
+
id: 'user_1',
|
|
323
|
+
email: 'a@b.com',
|
|
324
|
+
tier: null,
|
|
325
|
+
createdAt: '2026-06-13T00:00:00Z',
|
|
326
|
+
graceEndsAt: null,
|
|
327
|
+
},
|
|
328
|
+
apiKey: 'flowy_key',
|
|
329
|
+
checkoutUrl: null,
|
|
330
|
+
},
|
|
331
|
+
})
|
|
332
|
+
vi.doMock('../util/client.ts', () => ({ graphql: mockGraphql }))
|
|
333
|
+
mockSpawnSync.mockImplementation((cmd: string) =>
|
|
334
|
+
cmd === 'npx' ? { status: 1 } : { status: 0 },
|
|
335
|
+
)
|
|
336
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
337
|
+
|
|
338
|
+
const { setupCommand } = await import('./setup.ts')
|
|
339
|
+
await setupCommand.parseAsync(['remote', '--email', 'a@b.com'], {
|
|
340
|
+
from: 'user',
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
const warned = errSpy.mock.calls.map((c) => String(c[0])).join('\n')
|
|
344
|
+
expect(warned).toMatch(/skill/i)
|
|
345
|
+
expect(warned).toContain('npx skills add sqaoss/flowy')
|
|
346
|
+
expect(mockOutput).toHaveBeenCalled()
|
|
347
|
+
expect(mockOutputError).not.toHaveBeenCalled()
|
|
348
|
+
errSpy.mockRestore()
|
|
223
349
|
})
|
|
224
350
|
})
|
package/src/commands/setup.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
2
|
import { Command, Option } from 'commander'
|
|
3
|
-
import { loadConfig, saveConfig } from '../util/config.ts'
|
|
3
|
+
import { fingerprintKey, loadConfig, saveConfig } from '../util/config.ts'
|
|
4
4
|
import { output, outputError } from '../util/format.ts'
|
|
5
5
|
import { pinnedInstallSpec } from './serve.ts'
|
|
6
6
|
|
|
@@ -8,6 +8,32 @@ export const setupCommand = new Command('setup').description(
|
|
|
8
8
|
'Configure the Flowy CLI \u2014 use "flowy setup local" or "flowy setup remote"',
|
|
9
9
|
)
|
|
10
10
|
|
|
11
|
+
const SKILL_PACKAGE = 'sqaoss/flowy'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Install the Flowy agent skill, surfacing failure instead of swallowing it.
|
|
15
|
+
*
|
|
16
|
+
* `npx skills add` can fail (offline, npx unavailable, registry hiccup). If it
|
|
17
|
+
* does, setup should still succeed \u2014 but the user must be told the skill was
|
|
18
|
+
* NOT installed, with the exact command to retry, rather than silently
|
|
19
|
+
* assuming their agent now knows the commands (F14).
|
|
20
|
+
*/
|
|
21
|
+
function installSkill(): void {
|
|
22
|
+
const result = spawnSync('npx', ['skills', 'add', SKILL_PACKAGE, '--yes'], {
|
|
23
|
+
stdio: 'inherit',
|
|
24
|
+
})
|
|
25
|
+
if (result.error != null || result.status !== 0) {
|
|
26
|
+
const reason = result.error
|
|
27
|
+
? result.error.message
|
|
28
|
+
: `exit code ${result.status}`
|
|
29
|
+
console.error(
|
|
30
|
+
`Warning: failed to install the Flowy agent skill (${reason}). ` +
|
|
31
|
+
`Your agent will not know Flowy's commands until you install it manually:\n` +
|
|
32
|
+
` npx skills add ${SKILL_PACKAGE}`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
11
37
|
setupCommand
|
|
12
38
|
.command('local')
|
|
13
39
|
.description('Set up Flowy with a native local server (no Docker)')
|
|
@@ -26,9 +52,7 @@ setupCommand
|
|
|
26
52
|
config.mode = 'local'
|
|
27
53
|
config.apiUrl = apiUrl
|
|
28
54
|
saveConfig(config)
|
|
29
|
-
|
|
30
|
-
stdio: 'inherit',
|
|
31
|
-
})
|
|
55
|
+
installSkill()
|
|
32
56
|
|
|
33
57
|
output({
|
|
34
58
|
mode: 'local',
|
|
@@ -45,21 +69,21 @@ setupCommand
|
|
|
45
69
|
.command('remote')
|
|
46
70
|
.description('Connect to the hosted Flowy service')
|
|
47
71
|
.option('--email <email>', 'Email address for registration')
|
|
72
|
+
.option(
|
|
73
|
+
'--show-key',
|
|
74
|
+
'Print the full API key instead of a non-reversible fingerprint',
|
|
75
|
+
)
|
|
48
76
|
.addOption(
|
|
49
|
-
new Option(
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
|
|
53
|
-
]),
|
|
77
|
+
new Option(
|
|
78
|
+
'--tier <tier>',
|
|
79
|
+
'Subscription tier (optional — pick one later at checkout)',
|
|
80
|
+
).choices(['explorer', 'pro', 'team']),
|
|
54
81
|
)
|
|
55
82
|
.action(async (opts) => {
|
|
56
83
|
try {
|
|
57
84
|
if (!opts.email) {
|
|
58
85
|
throw new Error('--email is required for registration')
|
|
59
86
|
}
|
|
60
|
-
if (!opts.tier) {
|
|
61
|
-
throw new Error('--tier is required for registration')
|
|
62
|
-
}
|
|
63
87
|
|
|
64
88
|
const { graphql } = await import('../util/client.ts')
|
|
65
89
|
|
|
@@ -81,7 +105,7 @@ setupCommand
|
|
|
81
105
|
checkoutUrl: string
|
|
82
106
|
}
|
|
83
107
|
}>(
|
|
84
|
-
`mutation Register($email: String!, $tier: String
|
|
108
|
+
`mutation Register($email: String!, $tier: String) {
|
|
85
109
|
register(email: $email, tier: $tier) {
|
|
86
110
|
user { id email tier createdAt graceEndsAt }
|
|
87
111
|
apiKey
|
|
@@ -94,11 +118,15 @@ setupCommand
|
|
|
94
118
|
config.apiKey = data.register.apiKey
|
|
95
119
|
saveConfig(config)
|
|
96
120
|
|
|
97
|
-
|
|
98
|
-
stdio: 'inherit',
|
|
99
|
-
})
|
|
121
|
+
installSkill()
|
|
100
122
|
|
|
101
|
-
|
|
123
|
+
const { user, apiKey, checkoutUrl } = data.register
|
|
124
|
+
// Default output never leaks the secret; --show-key opts in (F35).
|
|
125
|
+
output(
|
|
126
|
+
opts.showKey
|
|
127
|
+
? { user, apiKey, checkoutUrl }
|
|
128
|
+
: { user, keyFingerprint: fingerprintKey(apiKey), checkoutUrl },
|
|
129
|
+
)
|
|
102
130
|
} catch (error) {
|
|
103
131
|
outputError(error)
|
|
104
132
|
}
|
|
@@ -3,10 +3,18 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
|
3
3
|
let mockGraphql: ReturnType<typeof vi.fn>
|
|
4
4
|
let mockOutput: ReturnType<typeof vi.fn>
|
|
5
5
|
let mockOutputError: ReturnType<typeof vi.fn>
|
|
6
|
+
let mockLoadConfig: ReturnType<typeof vi.fn>
|
|
6
7
|
|
|
7
8
|
beforeEach(() => {
|
|
8
9
|
mockOutput = vi.fn()
|
|
9
10
|
mockOutputError = vi.fn()
|
|
11
|
+
mockLoadConfig = vi.fn(() => ({
|
|
12
|
+
mode: 'saas',
|
|
13
|
+
apiUrl: 'https://flowy-ai.fly.dev/graphql',
|
|
14
|
+
apiKey: 'flowy_secret_abcdef0123456789',
|
|
15
|
+
client: { name: '' },
|
|
16
|
+
projects: {},
|
|
17
|
+
}))
|
|
10
18
|
mockGraphql = vi.fn().mockResolvedValue({
|
|
11
19
|
whoami: {
|
|
12
20
|
id: 'user_1',
|
|
@@ -25,6 +33,17 @@ beforeEach(() => {
|
|
|
25
33
|
vi.doMock('../util/client.ts', () => ({
|
|
26
34
|
graphql: mockGraphql,
|
|
27
35
|
}))
|
|
36
|
+
|
|
37
|
+
vi.doMock('../util/config.ts', async () => {
|
|
38
|
+
const actual =
|
|
39
|
+
await vi.importActual<typeof import('../util/config.ts')>(
|
|
40
|
+
'../util/config.ts',
|
|
41
|
+
)
|
|
42
|
+
return {
|
|
43
|
+
loadConfig: mockLoadConfig,
|
|
44
|
+
fingerprintKey: actual.fingerprintKey,
|
|
45
|
+
}
|
|
46
|
+
})
|
|
28
47
|
})
|
|
29
48
|
|
|
30
49
|
afterEach(() => {
|
|
@@ -33,7 +52,7 @@ afterEach(() => {
|
|
|
33
52
|
})
|
|
34
53
|
|
|
35
54
|
describe('whoami command', () => {
|
|
36
|
-
test('whoami outputs user data
|
|
55
|
+
test('whoami outputs user data plus a non-reversible key fingerprint', async () => {
|
|
37
56
|
const userData = {
|
|
38
57
|
id: '1',
|
|
39
58
|
email: 'a@b.com',
|
|
@@ -46,7 +65,17 @@ describe('whoami command', () => {
|
|
|
46
65
|
const { whoamiCommand } = await import('./whoami.ts')
|
|
47
66
|
await whoamiCommand.parseAsync([], { from: 'user' })
|
|
48
67
|
|
|
49
|
-
|
|
68
|
+
const outputArg = mockOutput.mock.calls[0]![0]
|
|
69
|
+
expect(outputArg).toEqual(
|
|
70
|
+
expect.objectContaining({
|
|
71
|
+
...userData,
|
|
72
|
+
keyFingerprint: expect.stringMatching(/sha256:[0-9a-f]{12}/),
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
// Fingerprint must not leak the configured secret.
|
|
76
|
+
expect(JSON.stringify(outputArg)).not.toContain(
|
|
77
|
+
'flowy_secret_abcdef0123456789',
|
|
78
|
+
)
|
|
50
79
|
})
|
|
51
80
|
|
|
52
81
|
test('whoami outputs error when query fails', async () => {
|
package/src/commands/whoami.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
|
+
import { fingerprintKey, loadConfig } from '../util/config.ts'
|
|
3
4
|
import { output, outputError } from '../util/format.ts'
|
|
4
5
|
|
|
5
6
|
export const whoamiCommand = new Command('whoami')
|
|
6
7
|
.description('Show current user info')
|
|
7
8
|
.action(async () => {
|
|
8
9
|
try {
|
|
9
|
-
const data = await graphql<{ whoami: unknown }>(
|
|
10
|
+
const data = await graphql<{ whoami: Record<string, unknown> }>(
|
|
10
11
|
`query Whoami {
|
|
11
12
|
whoami {
|
|
12
13
|
id email tier createdAt graceEndsAt
|
|
13
14
|
}
|
|
14
15
|
}`,
|
|
15
16
|
)
|
|
16
|
-
|
|
17
|
+
// Surface a non-reversible fingerprint of the configured key so a human
|
|
18
|
+
// can confirm *which* credential is active without exposing it (F35).
|
|
19
|
+
output({
|
|
20
|
+
...data.whoami,
|
|
21
|
+
keyFingerprint: fingerprintKey(loadConfig().apiKey),
|
|
22
|
+
})
|
|
17
23
|
} catch (error) {
|
|
18
24
|
outputError(error)
|
|
19
25
|
}
|
package/src/util/config.test.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
statSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { homedir, platform } from 'node:os'
|
|
3
10
|
import { resolve } from 'node:path'
|
|
4
11
|
import {
|
|
5
12
|
afterAll,
|
|
@@ -12,6 +19,8 @@ import {
|
|
|
12
19
|
vi,
|
|
13
20
|
} from 'vitest'
|
|
14
21
|
|
|
22
|
+
const isWindows = platform() === 'win32'
|
|
23
|
+
|
|
15
24
|
const CONFIG_PATH = resolve(homedir(), '.config', 'flowy', 'config.json')
|
|
16
25
|
|
|
17
26
|
describe('config', () => {
|
|
@@ -70,6 +79,45 @@ describe('config', () => {
|
|
|
70
79
|
expect(reloaded.client.name).toBe('Test Client')
|
|
71
80
|
})
|
|
72
81
|
|
|
82
|
+
test.skipIf(isWindows)(
|
|
83
|
+
'saveConfig writes config with 0600 mode',
|
|
84
|
+
async () => {
|
|
85
|
+
const { saveConfig, loadConfig } = await import('./config.ts')
|
|
86
|
+
saveConfig(loadConfig())
|
|
87
|
+
const mode = statSync(CONFIG_PATH).mode & 0o777
|
|
88
|
+
expect(mode).toBe(0o600)
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
test.skipIf(isWindows)(
|
|
93
|
+
'saveConfig corrects a pre-existing 0644 config to 0600',
|
|
94
|
+
async () => {
|
|
95
|
+
const { saveConfig, loadConfig } = await import('./config.ts')
|
|
96
|
+
// Simulate a config written by an older CLI (world-readable).
|
|
97
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(loadConfig(), null, 2))
|
|
98
|
+
chmodSync(CONFIG_PATH, 0o644)
|
|
99
|
+
expect(statSync(CONFIG_PATH).mode & 0o777).toBe(0o644)
|
|
100
|
+
|
|
101
|
+
saveConfig(loadConfig())
|
|
102
|
+
expect(statSync(CONFIG_PATH).mode & 0o777).toBe(0o600)
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
test('fingerprintKey is deterministic and non-reversible', async () => {
|
|
107
|
+
const { fingerprintKey } = await import('./config.ts')
|
|
108
|
+
const key = 'flowy_secret_abcdef0123456789'
|
|
109
|
+
const fp = fingerprintKey(key)
|
|
110
|
+
expect(fingerprintKey(key)).toBe(fp)
|
|
111
|
+
expect(fp).not.toContain(key)
|
|
112
|
+
expect(fp).not.toContain('abcdef0123456789')
|
|
113
|
+
expect(fp).toMatch(/sha256:[0-9a-f]{12}/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('fingerprintKey returns a placeholder for an empty key', async () => {
|
|
117
|
+
const { fingerprintKey } = await import('./config.ts')
|
|
118
|
+
expect(fingerprintKey('')).toBe('(none)')
|
|
119
|
+
})
|
|
120
|
+
|
|
73
121
|
test('resolveProject returns null when no project configured', async () => {
|
|
74
122
|
const { resolveProject } = await import('./config.ts')
|
|
75
123
|
expect(resolveProject()).toBeNull()
|
package/src/util/config.ts
CHANGED
|
@@ -1,10 +1,34 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { homedir, platform } from 'node:os'
|
|
3
10
|
import { resolve } from 'node:path'
|
|
4
11
|
|
|
5
12
|
const CONFIG_DIR = resolve(homedir(), '.config', 'flowy')
|
|
6
13
|
const CONFIG_PATH = resolve(CONFIG_DIR, 'config.json')
|
|
7
14
|
|
|
15
|
+
// Owner-only modes for the config dir/file, which hold the FLOWY_API_KEY.
|
|
16
|
+
// POSIX-only; chmod is a no-op on Windows so we skip it to avoid surprises.
|
|
17
|
+
const DIR_MODE = 0o700
|
|
18
|
+
const FILE_MODE = 0o600
|
|
19
|
+
const isWindows = platform() === 'win32'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Non-reversible fingerprint of an API key, safe to print to stdout/logs.
|
|
23
|
+
* A short SHA-256 prefix lets a human confirm *which* key is configured
|
|
24
|
+
* without exposing the secret itself (F35).
|
|
25
|
+
*/
|
|
26
|
+
export function fingerprintKey(apiKey: string): string {
|
|
27
|
+
if (!apiKey) return '(none)'
|
|
28
|
+
const digest = createHash('sha256').update(apiKey).digest('hex')
|
|
29
|
+
return `sha256:${digest.slice(0, 12)}`
|
|
30
|
+
}
|
|
31
|
+
|
|
8
32
|
export function loadConfig() {
|
|
9
33
|
if (!existsSync(CONFIG_PATH)) {
|
|
10
34
|
return {
|
|
@@ -19,8 +43,17 @@ export function loadConfig() {
|
|
|
19
43
|
}
|
|
20
44
|
|
|
21
45
|
export function saveConfig(config: ReturnType<typeof loadConfig>): void {
|
|
22
|
-
mkdirSync(CONFIG_DIR, { recursive: true })
|
|
23
|
-
writeFileSync
|
|
46
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: DIR_MODE })
|
|
47
|
+
// `mode` on writeFileSync only applies to *newly created* files and is
|
|
48
|
+
// masked by umask, so an explicit chmod afterward both corrects a
|
|
49
|
+
// pre-existing world-readable (0644) config and survives a tight umask.
|
|
50
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), {
|
|
51
|
+
mode: FILE_MODE,
|
|
52
|
+
})
|
|
53
|
+
if (!isWindows) {
|
|
54
|
+
chmodSync(CONFIG_DIR, DIR_MODE)
|
|
55
|
+
chmodSync(CONFIG_PATH, FILE_MODE)
|
|
56
|
+
}
|
|
24
57
|
}
|
|
25
58
|
|
|
26
59
|
export interface ProjectConfig {
|