@sqaoss/flowy 1.6.1 → 1.8.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 +14 -0
- package/package.json +2 -1
- package/server/src/contract.test.ts +407 -0
- package/src/commands/approve.ts +2 -6
- package/src/commands/billing.test.ts +32 -0
- package/src/commands/billing.ts +4 -5
- package/src/commands/export.ts +8 -19
- package/src/commands/feature.ts +28 -48
- package/src/commands/import.ts +14 -24
- package/src/commands/init.ts +2 -5
- package/src/commands/key.test.ts +81 -8
- package/src/commands/key.ts +22 -12
- package/src/commands/project.ts +23 -43
- package/src/commands/search.ts +7 -13
- package/src/commands/setup.test.ts +95 -7
- package/src/commands/setup.ts +14 -12
- package/src/commands/status.ts +5 -8
- package/src/commands/task.ts +48 -87
- package/src/commands/tree.ts +5 -8
- package/src/commands/whoami.test.ts +64 -2
- package/src/commands/whoami.ts +14 -8
- package/src/util/config.test.ts +218 -3
- package/src/util/config.ts +224 -21
- package/src/util/operations.test.ts +114 -0
- package/src/util/operations.ts +331 -0
package/README.md
CHANGED
|
@@ -189,6 +189,20 @@ The self-hosted server supports the full planning workflow — `init`, `project`
|
|
|
189
189
|
|
|
190
190
|
All commands output JSON to stdout; errors go to stderr as `{ "error": "message" }`.
|
|
191
191
|
|
|
192
|
+
## GraphQL API
|
|
193
|
+
|
|
194
|
+
The CLI is a thin client over a GraphQL API. To integrate directly — or to
|
|
195
|
+
understand what the CLI sends — see the API reference for the bundled local
|
|
196
|
+
server:
|
|
197
|
+
|
|
198
|
+
- **[docs/API.md](docs/API.md)** — schema, example queries/mutations,
|
|
199
|
+
error-code catalogue (with CLI exit codes), and limits.
|
|
200
|
+
- **[docs/api/schema.graphql](docs/api/schema.graphql)** — the full SDL,
|
|
201
|
+
regenerable with `bun run sdl`.
|
|
202
|
+
|
|
203
|
+
The hosted service at `flowy-ai.fly.dev` exposes a superset of this schema plus
|
|
204
|
+
account/billing operations; its API is documented separately.
|
|
205
|
+
|
|
192
206
|
## Configuration
|
|
193
207
|
|
|
194
208
|
Config is stored at `~/.config/flowy/config.json`. These environment variables override config:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sqaoss/flowy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Agentic persistent planning",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"check": "biome check --write .",
|
|
51
51
|
"test": "vitest run",
|
|
52
52
|
"test:watch": "vitest",
|
|
53
|
+
"sdl": "bun scripts/print-sdl.ts",
|
|
53
54
|
"typecheck": "tsc --noEmit",
|
|
54
55
|
"prepare": "husky"
|
|
55
56
|
},
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI <-> bundled-local-server CONTRACT GUARD (Flowy improvement plan P1-1).
|
|
3
|
+
*
|
|
4
|
+
* This test executes every GraphQL operation the Flowy CLI sends (the canonical
|
|
5
|
+
* set in `src/util/operations.ts`) against a live instance of the *bundled
|
|
6
|
+
* local server* (`server/src/index.ts`'s `createServer`), over its real HTTP +
|
|
7
|
+
* GraphQL transport. Because Yoga parses and validates each operation against
|
|
8
|
+
* the server's schema before executing it, this catches drift the unit tests
|
|
9
|
+
* (which call resolvers directly) cannot:
|
|
10
|
+
*
|
|
11
|
+
* - a renamed query/mutation (e.g. `readyTasks` -> `actionableTasks`),
|
|
12
|
+
* - a renamed or dropped field on a returned type (e.g. `Node.metadata`),
|
|
13
|
+
* - a renamed, dropped, or retyped argument (e.g. `search(limit:)`),
|
|
14
|
+
*
|
|
15
|
+
* any of which would make the CLI break at runtime. If you rename anything the
|
|
16
|
+
* CLI relies on, this test fails — forcing the CLI and the local server to move
|
|
17
|
+
* together.
|
|
18
|
+
*
|
|
19
|
+
* Scope: this guards the LOCAL side of the contract. The hosted `flowy-saas`
|
|
20
|
+
* repo keeps its own mirror of these query strings in
|
|
21
|
+
* `test/helpers/cli-queries.ts` and should assert them the same way against its
|
|
22
|
+
* Postgres schema.
|
|
23
|
+
*
|
|
24
|
+
* ── Intentional local / SaaS divergences (documented, NOT failures) ──────────
|
|
25
|
+
* The CLI's operations module also exports SAAS_ONLY_OPERATIONS. The bundled
|
|
26
|
+
* local server deliberately does NOT implement these — they require auth,
|
|
27
|
+
* billing, or audit infrastructure that only exists in the hosted backend:
|
|
28
|
+
*
|
|
29
|
+
* - `register` (setup.ts) — account creation; local has no accounts.
|
|
30
|
+
* - `whoami` (whoami.ts) — current user; local is unauthenticated.
|
|
31
|
+
* - `rotateApiKey` (key.ts) — API key lifecycle; local has no keys.
|
|
32
|
+
* - `createCheckout` (billing.ts)— Polar checkout; local has no billing.
|
|
33
|
+
*
|
|
34
|
+
* Conversely, the SaaS schema carries operations the local server lacks and the
|
|
35
|
+
* CLI does not yet call against local: `auditLog`/`getAuditLog` (P1-2 will port
|
|
36
|
+
* this to local), `ancestors`, and `nodes(status/limit/offset)` pagination.
|
|
37
|
+
* Status vocabulary and edge relations are shared (`part_of`, `blocks`); the
|
|
38
|
+
* SaaS schema additionally recognises `epic`/`depends_on`/`informs` relations
|
|
39
|
+
* the bundled server does not. The test below asserts the local server rejects
|
|
40
|
+
* each SAAS_ONLY operation (proving the divergence is real and explicit), and
|
|
41
|
+
* that it satisfies every LOCAL_CONTRACT_OPERATIONS entry.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
45
|
+
import {
|
|
46
|
+
LOCAL_CONTRACT_OPERATIONS,
|
|
47
|
+
SAAS_ONLY_OPERATIONS,
|
|
48
|
+
} from '../../src/util/operations.ts'
|
|
49
|
+
import { createServer } from './index.ts'
|
|
50
|
+
|
|
51
|
+
let instance: ReturnType<typeof createServer>
|
|
52
|
+
let endpoint: string
|
|
53
|
+
|
|
54
|
+
/** Run an operation through the live server's HTTP/GraphQL transport. */
|
|
55
|
+
async function run<T = Record<string, unknown>>(
|
|
56
|
+
query: string,
|
|
57
|
+
variables?: Record<string, unknown>,
|
|
58
|
+
): Promise<{ data: T | null; errors?: Array<{ message: string }> }> {
|
|
59
|
+
const res = await fetch(endpoint, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ query, variables }),
|
|
63
|
+
})
|
|
64
|
+
return (await res.json()) as {
|
|
65
|
+
data: T | null
|
|
66
|
+
errors?: Array<{ message: string }>
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Run an operation and fail loudly if the server reports any GraphQL error. */
|
|
71
|
+
async function ok<T = Record<string, unknown>>(
|
|
72
|
+
query: string,
|
|
73
|
+
variables?: Record<string, unknown>,
|
|
74
|
+
): Promise<T> {
|
|
75
|
+
const body = await run<T>(query, variables)
|
|
76
|
+
expect(
|
|
77
|
+
body.errors,
|
|
78
|
+
`operation produced GraphQL errors: ${JSON.stringify(body.errors)}`,
|
|
79
|
+
).toBeUndefined()
|
|
80
|
+
expect(body.data).not.toBeNull()
|
|
81
|
+
return body.data as T
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
beforeAll(() => {
|
|
85
|
+
instance = createServer({ dbPath: ':memory:', port: 0 })
|
|
86
|
+
endpoint = `http://localhost:${instance.port}/graphql`
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
afterAll(() => {
|
|
90
|
+
instance.close()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('CLI/local-server contract (P1-1)', () => {
|
|
94
|
+
it('exercises every CLI operation against the bundled local server', async () => {
|
|
95
|
+
// --- Mutations: build a project -> feature -> task hierarchy ------------
|
|
96
|
+
const { createNode: project } = await ok<{
|
|
97
|
+
createNode: { id: string; type: string; status: string }
|
|
98
|
+
}>(LOCAL_CONTRACT_OPERATIONS.CREATE_PROJECT, {
|
|
99
|
+
type: 'project',
|
|
100
|
+
title: 'Contract Project',
|
|
101
|
+
})
|
|
102
|
+
expect(project.id).toMatch(/^proj_/)
|
|
103
|
+
expect(project.status).toBe('draft')
|
|
104
|
+
|
|
105
|
+
const { createNode: feature } = await ok<{
|
|
106
|
+
createNode: { id: string; description: string | null }
|
|
107
|
+
}>(LOCAL_CONTRACT_OPERATIONS.CREATE_NODE, {
|
|
108
|
+
type: 'feature',
|
|
109
|
+
title: 'Contract Feature',
|
|
110
|
+
description: 'A feature',
|
|
111
|
+
})
|
|
112
|
+
expect(feature.id).toMatch(/^feat_/)
|
|
113
|
+
expect(feature.description).toBe('A feature')
|
|
114
|
+
|
|
115
|
+
const { createNode: task } = await ok<{
|
|
116
|
+
createNode: { id: string }
|
|
117
|
+
}>(LOCAL_CONTRACT_OPERATIONS.CREATE_TASK, {
|
|
118
|
+
type: 'task',
|
|
119
|
+
title: 'Contract Task',
|
|
120
|
+
description: 'Do the thing',
|
|
121
|
+
})
|
|
122
|
+
expect(task.id).toMatch(/^task_/)
|
|
123
|
+
|
|
124
|
+
const { createNode: blocker } = await ok<{ createNode: { id: string } }>(
|
|
125
|
+
LOCAL_CONTRACT_OPERATIONS.CREATE_TASK,
|
|
126
|
+
{ type: 'task', title: 'Blocker Task', description: 'first' },
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// import.ts CREATE/UPDATE carry status + metadata; verify they round-trip.
|
|
130
|
+
const { createNode: imported } = await ok<{ createNode: { id: string } }>(
|
|
131
|
+
LOCAL_CONTRACT_OPERATIONS.IMPORT_CREATE,
|
|
132
|
+
{
|
|
133
|
+
type: 'task',
|
|
134
|
+
title: 'Imported Task',
|
|
135
|
+
description: 'imported',
|
|
136
|
+
status: 'in_progress',
|
|
137
|
+
metadata: JSON.stringify({ clientKey: 'k-1' }),
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
expect(imported.id).toMatch(/^task_/)
|
|
141
|
+
|
|
142
|
+
await ok(LOCAL_CONTRACT_OPERATIONS.IMPORT_UPDATE, {
|
|
143
|
+
id: imported.id,
|
|
144
|
+
title: 'Imported Task v2',
|
|
145
|
+
description: 'updated',
|
|
146
|
+
status: 'done',
|
|
147
|
+
metadata: JSON.stringify({ clientKey: 'k-1' }),
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// --- Edges: part_of hierarchy + a blocks dependency --------------------
|
|
151
|
+
const { createEdge: featureEdge } = await ok<{
|
|
152
|
+
createEdge: { sourceId: string; targetId: string; relation: string }
|
|
153
|
+
}>(LOCAL_CONTRACT_OPERATIONS.CREATE_EDGE, {
|
|
154
|
+
sourceId: feature.id,
|
|
155
|
+
targetId: project.id,
|
|
156
|
+
relation: 'part_of',
|
|
157
|
+
})
|
|
158
|
+
expect(featureEdge.relation).toBe('part_of')
|
|
159
|
+
|
|
160
|
+
await ok(LOCAL_CONTRACT_OPERATIONS.LINK_TASK, {
|
|
161
|
+
sourceId: task.id,
|
|
162
|
+
targetId: feature.id,
|
|
163
|
+
relation: 'part_of',
|
|
164
|
+
})
|
|
165
|
+
await ok(LOCAL_CONTRACT_OPERATIONS.LINK_TASK, {
|
|
166
|
+
sourceId: blocker.id,
|
|
167
|
+
targetId: feature.id,
|
|
168
|
+
relation: 'part_of',
|
|
169
|
+
})
|
|
170
|
+
await ok(LOCAL_CONTRACT_OPERATIONS.IMPORT_EDGE, {
|
|
171
|
+
sourceId: imported.id,
|
|
172
|
+
targetId: feature.id,
|
|
173
|
+
relation: 'part_of',
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// blocker blocks task
|
|
177
|
+
const { createEdge: blocksEdge } = await ok<{
|
|
178
|
+
createEdge: { relation: string; createdAt: string }
|
|
179
|
+
}>(LOCAL_CONTRACT_OPERATIONS.BLOCK_TASK, {
|
|
180
|
+
sourceId: blocker.id,
|
|
181
|
+
targetId: task.id,
|
|
182
|
+
relation: 'blocks',
|
|
183
|
+
})
|
|
184
|
+
expect(blocksEdge.relation).toBe('blocks')
|
|
185
|
+
expect(typeof blocksEdge.createdAt).toBe('string')
|
|
186
|
+
|
|
187
|
+
// --- Status / approval -------------------------------------------------
|
|
188
|
+
const { updateNode: statusUpdated } = await ok<{
|
|
189
|
+
updateNode: { status: string; updatedAt: string }
|
|
190
|
+
}>(LOCAL_CONTRACT_OPERATIONS.UPDATE_STATUS, {
|
|
191
|
+
id: task.id,
|
|
192
|
+
status: 'pending_review',
|
|
193
|
+
})
|
|
194
|
+
expect(statusUpdated.status).toBe('pending_review')
|
|
195
|
+
|
|
196
|
+
const { approveNode } = await ok<{ approveNode: { status: string } }>(
|
|
197
|
+
LOCAL_CONTRACT_OPERATIONS.APPROVE_NODE,
|
|
198
|
+
{ id: task.id },
|
|
199
|
+
)
|
|
200
|
+
expect(approveNode.status).toBe('approved')
|
|
201
|
+
|
|
202
|
+
// update content (title/description/metadata)
|
|
203
|
+
const { updateNode } = await ok<{
|
|
204
|
+
updateNode: { title: string; metadata: string | null }
|
|
205
|
+
}>(LOCAL_CONTRACT_OPERATIONS.UPDATE_NODE, {
|
|
206
|
+
id: task.id,
|
|
207
|
+
title: 'Renamed Task',
|
|
208
|
+
description: 'New body',
|
|
209
|
+
metadata: JSON.stringify({ note: 'x' }),
|
|
210
|
+
})
|
|
211
|
+
expect(updateNode.title).toBe('Renamed Task')
|
|
212
|
+
expect(updateNode.metadata).toContain('note')
|
|
213
|
+
|
|
214
|
+
// --- Queries: reads ----------------------------------------------------
|
|
215
|
+
const getNode = await ok<{ node: { id: string } | null }>(
|
|
216
|
+
LOCAL_CONTRACT_OPERATIONS.GET_NODE,
|
|
217
|
+
{ id: feature.id },
|
|
218
|
+
)
|
|
219
|
+
expect(getNode.node?.id).toBe(feature.id)
|
|
220
|
+
|
|
221
|
+
const getProject = await ok<{ node: { id: string } | null }>(
|
|
222
|
+
LOCAL_CONTRACT_OPERATIONS.GET_PROJECT,
|
|
223
|
+
{ id: project.id },
|
|
224
|
+
)
|
|
225
|
+
expect(getProject.node?.id).toBe(project.id)
|
|
226
|
+
|
|
227
|
+
const exportProject = await ok<{
|
|
228
|
+
node: { metadata: string | null } | null
|
|
229
|
+
}>(LOCAL_CONTRACT_OPERATIONS.EXPORT_PROJECT, { id: project.id })
|
|
230
|
+
expect(exportProject.node).not.toBeNull()
|
|
231
|
+
|
|
232
|
+
const listForSet = await ok<{
|
|
233
|
+
nodes: Array<{ id: string; title: string }>
|
|
234
|
+
}>(LOCAL_CONTRACT_OPERATIONS.LIST_PROJECTS_FOR_SET, { type: 'project' })
|
|
235
|
+
expect(listForSet.nodes.some((n) => n.id === project.id)).toBe(true)
|
|
236
|
+
|
|
237
|
+
const listProjects = await ok<{ nodes: unknown[] }>(
|
|
238
|
+
LOCAL_CONTRACT_OPERATIONS.LIST_PROJECTS,
|
|
239
|
+
{ type: 'project' },
|
|
240
|
+
)
|
|
241
|
+
expect(listProjects.nodes.length).toBeGreaterThan(0)
|
|
242
|
+
|
|
243
|
+
const allTasks = await ok<{ nodes: Array<{ type: string }> }>(
|
|
244
|
+
LOCAL_CONTRACT_OPERATIONS.ALL_TASKS,
|
|
245
|
+
{ type: 'task' },
|
|
246
|
+
)
|
|
247
|
+
expect(allTasks.nodes.every((n) => n.type === 'task')).toBe(true)
|
|
248
|
+
|
|
249
|
+
const importExisting = await ok<{
|
|
250
|
+
nodes: Array<{ metadata: string | null }>
|
|
251
|
+
}>(LOCAL_CONTRACT_OPERATIONS.IMPORT_EXISTING, { type: 'task' })
|
|
252
|
+
expect(importExisting.nodes.length).toBeGreaterThan(0)
|
|
253
|
+
|
|
254
|
+
const descendants = await ok<{ descendants: Array<{ id: string }> }>(
|
|
255
|
+
LOCAL_CONTRACT_OPERATIONS.DESCENDANTS,
|
|
256
|
+
{ nodeId: project.id, relation: 'part_of', maxDepth: 1 },
|
|
257
|
+
)
|
|
258
|
+
expect(descendants.descendants.some((n) => n.id === feature.id)).toBe(true)
|
|
259
|
+
|
|
260
|
+
const descendantsBrief = await ok<{
|
|
261
|
+
descendants: Array<{ status: string }>
|
|
262
|
+
}>(LOCAL_CONTRACT_OPERATIONS.DESCENDANTS_BRIEF, {
|
|
263
|
+
nodeId: project.id,
|
|
264
|
+
relation: 'part_of',
|
|
265
|
+
maxDepth: 1,
|
|
266
|
+
})
|
|
267
|
+
expect(descendantsBrief.descendants.length).toBeGreaterThan(0)
|
|
268
|
+
|
|
269
|
+
const listTasks = await ok<{ descendants: unknown[] }>(
|
|
270
|
+
LOCAL_CONTRACT_OPERATIONS.LIST_TASKS,
|
|
271
|
+
{ nodeId: feature.id, relation: 'part_of', maxDepth: 1 },
|
|
272
|
+
)
|
|
273
|
+
expect(listTasks.descendants.length).toBeGreaterThan(0)
|
|
274
|
+
|
|
275
|
+
const exportDescendants = await ok<{
|
|
276
|
+
descendants: Array<{ metadata: string | null }>
|
|
277
|
+
}>(LOCAL_CONTRACT_OPERATIONS.EXPORT_DESCENDANTS, {
|
|
278
|
+
nodeId: project.id,
|
|
279
|
+
relation: 'part_of',
|
|
280
|
+
maxDepth: 100,
|
|
281
|
+
})
|
|
282
|
+
expect(exportDescendants.descendants.length).toBeGreaterThan(0)
|
|
283
|
+
|
|
284
|
+
const subtree = await ok<{ subtree: Array<{ id: string }> }>(
|
|
285
|
+
LOCAL_CONTRACT_OPERATIONS.SUBTREE,
|
|
286
|
+
{ nodeId: project.id, maxDepth: 10 },
|
|
287
|
+
)
|
|
288
|
+
expect(subtree.subtree.length).toBeGreaterThan(0)
|
|
289
|
+
|
|
290
|
+
// task is blocked by an unfinished blocker, so readyTasks excludes it.
|
|
291
|
+
const ready = await ok<{ readyTasks: Array<{ id: string }> }>(
|
|
292
|
+
LOCAL_CONTRACT_OPERATIONS.READY_TASKS,
|
|
293
|
+
{ projectId: project.id },
|
|
294
|
+
)
|
|
295
|
+
const readyIds = ready.readyTasks.map((t) => t.id)
|
|
296
|
+
expect(readyIds).toContain(blocker.id)
|
|
297
|
+
expect(readyIds).not.toContain(task.id)
|
|
298
|
+
|
|
299
|
+
// edges via SHOW_TASK: blockedBy (incoming) + blocks (outgoing)
|
|
300
|
+
const showTask = await ok<{
|
|
301
|
+
node: { id: string } | null
|
|
302
|
+
blockedBy: Array<{ id: string }>
|
|
303
|
+
blocks: Array<{ id: string }>
|
|
304
|
+
}>(LOCAL_CONTRACT_OPERATIONS.SHOW_TASK, { id: task.id })
|
|
305
|
+
expect(showTask.node?.id).toBe(task.id)
|
|
306
|
+
expect(showTask.blockedBy.map((n) => n.id)).toContain(blocker.id)
|
|
307
|
+
|
|
308
|
+
const taskDeps = await ok<{
|
|
309
|
+
blockedBy: Array<{ id: string }>
|
|
310
|
+
blocks: Array<{ id: string }>
|
|
311
|
+
}>(LOCAL_CONTRACT_OPERATIONS.TASK_DEPS, { id: blocker.id })
|
|
312
|
+
expect(taskDeps.blocks.map((n) => n.id)).toContain(task.id)
|
|
313
|
+
|
|
314
|
+
const importEdges = await ok<{ edges: Array<{ id: string }> }>(
|
|
315
|
+
LOCAL_CONTRACT_OPERATIONS.IMPORT_EDGES,
|
|
316
|
+
{ nodeId: feature.id, relation: 'part_of' },
|
|
317
|
+
)
|
|
318
|
+
expect(Array.isArray(importEdges.edges)).toBe(true)
|
|
319
|
+
|
|
320
|
+
const exportEdges = await ok<{
|
|
321
|
+
edges: Array<{ id: string; metadata: string | null }>
|
|
322
|
+
}>(LOCAL_CONTRACT_OPERATIONS.EXPORT_EDGES, {
|
|
323
|
+
nodeId: feature.id,
|
|
324
|
+
relation: 'part_of',
|
|
325
|
+
})
|
|
326
|
+
expect(Array.isArray(exportEdges.edges)).toBe(true)
|
|
327
|
+
|
|
328
|
+
const search = await ok<{ search: Array<{ id: string }> }>(
|
|
329
|
+
LOCAL_CONTRACT_OPERATIONS.SEARCH,
|
|
330
|
+
{ query: 'Contract', type: 'project', status: null, limit: 50 },
|
|
331
|
+
)
|
|
332
|
+
expect(search.search.some((n) => n.id === project.id)).toBe(true)
|
|
333
|
+
|
|
334
|
+
// --- Edge removal + node deletion --------------------------------------
|
|
335
|
+
const { removeEdge } = await ok<{ removeEdge: boolean }>(
|
|
336
|
+
LOCAL_CONTRACT_OPERATIONS.UNBLOCK_TASK,
|
|
337
|
+
{ sourceId: blocker.id, targetId: task.id, relation: 'blocks' },
|
|
338
|
+
)
|
|
339
|
+
expect(removeEdge).toBe(true)
|
|
340
|
+
|
|
341
|
+
const { deleteNode } = await ok<{ deleteNode: boolean }>(
|
|
342
|
+
LOCAL_CONTRACT_OPERATIONS.DELETE_NODE,
|
|
343
|
+
{ id: task.id },
|
|
344
|
+
)
|
|
345
|
+
expect(deleteNode).toBe(true)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('covers the entire LOCAL_CONTRACT_OPERATIONS set (no op left untested)', () => {
|
|
349
|
+
// Guards against silently adding a CLI operation that the contract above
|
|
350
|
+
// never exercises. Keep this list in lockstep with the assertions above.
|
|
351
|
+
const exercised = new Set([
|
|
352
|
+
'GET_NODE',
|
|
353
|
+
'GET_PROJECT',
|
|
354
|
+
'LIST_PROJECTS_FOR_SET',
|
|
355
|
+
'LIST_PROJECTS',
|
|
356
|
+
'ALL_TASKS',
|
|
357
|
+
'DESCENDANTS',
|
|
358
|
+
'DESCENDANTS_BRIEF',
|
|
359
|
+
'LIST_TASKS',
|
|
360
|
+
'SUBTREE',
|
|
361
|
+
'READY_TASKS',
|
|
362
|
+
'SHOW_TASK',
|
|
363
|
+
'TASK_DEPS',
|
|
364
|
+
'SEARCH',
|
|
365
|
+
'CREATE_PROJECT',
|
|
366
|
+
'CREATE_NODE',
|
|
367
|
+
'CREATE_TASK',
|
|
368
|
+
'UPDATE_NODE',
|
|
369
|
+
'UPDATE_STATUS',
|
|
370
|
+
'APPROVE_NODE',
|
|
371
|
+
'DELETE_NODE',
|
|
372
|
+
'CREATE_EDGE',
|
|
373
|
+
'LINK_TASK',
|
|
374
|
+
'BLOCK_TASK',
|
|
375
|
+
'UNBLOCK_TASK',
|
|
376
|
+
'IMPORT_EXISTING',
|
|
377
|
+
'IMPORT_EDGES',
|
|
378
|
+
'IMPORT_CREATE',
|
|
379
|
+
'IMPORT_UPDATE',
|
|
380
|
+
'IMPORT_EDGE',
|
|
381
|
+
'EXPORT_PROJECT',
|
|
382
|
+
'EXPORT_DESCENDANTS',
|
|
383
|
+
'EXPORT_EDGES',
|
|
384
|
+
])
|
|
385
|
+
expect(new Set(Object.keys(LOCAL_CONTRACT_OPERATIONS))).toEqual(exercised)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
describe('intentional SaaS-only divergences (must NOT resolve on local)', () => {
|
|
389
|
+
for (const [name, query] of Object.entries(SAAS_ONLY_OPERATIONS)) {
|
|
390
|
+
it(`local server rejects ${name}`, async () => {
|
|
391
|
+
const variables: Record<string, Record<string, unknown>> = {
|
|
392
|
+
REGISTER: { email: 'a@b.co', tier: 'pro' },
|
|
393
|
+
CREATE_CHECKOUT: { tier: 'pro' },
|
|
394
|
+
}
|
|
395
|
+
const body = await run(query, variables[name] ?? {})
|
|
396
|
+
// The bundled local server's schema has no register/whoami/
|
|
397
|
+
// rotateApiKey/createCheckout, so Yoga rejects with a validation error
|
|
398
|
+
// ("Cannot query field ..." / "Unknown ..."). This is the contract: the
|
|
399
|
+
// CLI must NOT route these to a local backend.
|
|
400
|
+
expect(
|
|
401
|
+
body.errors,
|
|
402
|
+
`${name} unexpectedly resolved on the local server`,
|
|
403
|
+
).toBeDefined()
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
})
|
|
407
|
+
})
|
package/src/commands/approve.ts
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
3
|
import { output, outputError } from '../util/format.ts'
|
|
4
|
+
import { APPROVE_NODE } from '../util/operations.ts'
|
|
4
5
|
|
|
5
6
|
export const approveCommand = new Command('approve')
|
|
6
7
|
.description('Approve a node (must be in pending_review)')
|
|
7
8
|
.argument('<id>', 'Node ID')
|
|
8
9
|
.action(async (id: string) => {
|
|
9
10
|
try {
|
|
10
|
-
const data = await graphql<{ approveNode: unknown }>(
|
|
11
|
-
`mutation ApproveNode($id: String!) {
|
|
12
|
-
approveNode(id: $id) { id type title status updatedAt }
|
|
13
|
-
}`,
|
|
14
|
-
{ id },
|
|
15
|
-
)
|
|
11
|
+
const data = await graphql<{ approveNode: unknown }>(APPROVE_NODE, { id })
|
|
16
12
|
output(data.approveNode)
|
|
17
13
|
} catch (error) {
|
|
18
14
|
outputError(error)
|
|
@@ -3,11 +3,13 @@ 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 mockMode: 'local' | 'remote'
|
|
6
7
|
|
|
7
8
|
beforeEach(() => {
|
|
8
9
|
mockGraphql = vi.fn()
|
|
9
10
|
mockOutput = vi.fn()
|
|
10
11
|
mockOutputError = vi.fn()
|
|
12
|
+
mockMode = 'remote'
|
|
11
13
|
|
|
12
14
|
vi.doMock('../util/client.ts', () => ({
|
|
13
15
|
graphql: mockGraphql,
|
|
@@ -17,6 +19,18 @@ beforeEach(() => {
|
|
|
17
19
|
output: mockOutput,
|
|
18
20
|
outputError: mockOutputError,
|
|
19
21
|
}))
|
|
22
|
+
|
|
23
|
+
vi.doMock('../util/config.ts', () => ({
|
|
24
|
+
requireRemoteMode: (commandName: string) => {
|
|
25
|
+
if (mockMode === 'local') {
|
|
26
|
+
const err = new Error(
|
|
27
|
+
`"flowy ${commandName}" is only available in remote mode. The active mode is local mode.`,
|
|
28
|
+
) as Error & { code?: string }
|
|
29
|
+
err.code = 'LOCAL_MODE'
|
|
30
|
+
throw err
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
}))
|
|
20
34
|
})
|
|
21
35
|
|
|
22
36
|
afterEach(() => {
|
|
@@ -94,4 +108,22 @@ describe('billing command', () => {
|
|
|
94
108
|
|
|
95
109
|
expect(mockOutputError).toHaveBeenCalledWith(error)
|
|
96
110
|
})
|
|
111
|
+
|
|
112
|
+
test('checkout errors cleanly in local mode without hitting the server', async () => {
|
|
113
|
+
mockMode = 'local'
|
|
114
|
+
|
|
115
|
+
const { billingCommand } = await import('./billing.ts')
|
|
116
|
+
await billingCommand.parseAsync(['checkout', '--tier', 'pro'], {
|
|
117
|
+
from: 'user',
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(mockGraphql).not.toHaveBeenCalled()
|
|
121
|
+
expect(mockOutput).not.toHaveBeenCalled()
|
|
122
|
+
expect(mockOutputError).toHaveBeenCalledWith(
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
message: expect.stringMatching(/local mode/i),
|
|
125
|
+
code: 'LOCAL_MODE',
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
})
|
|
97
129
|
})
|
package/src/commands/billing.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Command, Option } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
|
+
import { requireRemoteMode } from '../util/config.ts'
|
|
3
4
|
import { output, outputError } from '../util/format.ts'
|
|
5
|
+
import { CREATE_CHECKOUT } from '../util/operations.ts'
|
|
4
6
|
|
|
5
7
|
const checkoutCommand = new Command('checkout')
|
|
6
8
|
.description('Create a checkout session for a subscription tier')
|
|
@@ -11,12 +13,9 @@ const checkoutCommand = new Command('checkout')
|
|
|
11
13
|
)
|
|
12
14
|
.action(async (opts: { tier: string }) => {
|
|
13
15
|
try {
|
|
16
|
+
requireRemoteMode('billing checkout')
|
|
14
17
|
const data = await graphql<{ createCheckout: { url: string } }>(
|
|
15
|
-
|
|
16
|
-
createCheckout(tier: $tier) {
|
|
17
|
-
url
|
|
18
|
-
}
|
|
19
|
-
}`,
|
|
18
|
+
CREATE_CHECKOUT,
|
|
20
19
|
{ tier: opts.tier },
|
|
21
20
|
)
|
|
22
21
|
output(data.createCheckout)
|
package/src/commands/export.ts
CHANGED
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
serializeManifest,
|
|
13
13
|
stripClientKey,
|
|
14
14
|
} from '../util/manifest.ts'
|
|
15
|
+
import {
|
|
16
|
+
EXPORT_DESCENDANTS,
|
|
17
|
+
EXPORT_EDGES,
|
|
18
|
+
EXPORT_PROJECT,
|
|
19
|
+
} from '../util/operations.ts'
|
|
15
20
|
|
|
16
21
|
/** Relations export captures from the real edge model. */
|
|
17
22
|
const RELATIONS = ['part_of', 'blocks'] as const
|
|
@@ -25,22 +30,6 @@ interface ServerNode {
|
|
|
25
30
|
metadata: string | null
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
const PROJECT_QUERY = `query ExportProject($id: String!) {
|
|
29
|
-
node(id: $id) { id type title description status metadata }
|
|
30
|
-
}`
|
|
31
|
-
|
|
32
|
-
const DESCENDANTS_QUERY = `query ExportDescendants($nodeId: String!, $relation: String, $maxDepth: Int) {
|
|
33
|
-
descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
|
|
34
|
-
id type title description status metadata
|
|
35
|
-
}
|
|
36
|
-
}`
|
|
37
|
-
|
|
38
|
-
const EDGES_QUERY = `query ExportEdges($nodeId: String!, $relation: String!) {
|
|
39
|
-
edges(nodeId: $nodeId, relation: $relation, direction: "outgoing") {
|
|
40
|
-
id metadata
|
|
41
|
-
}
|
|
42
|
-
}`
|
|
43
|
-
|
|
44
33
|
export const exportCommand = new Command('export')
|
|
45
34
|
.description(
|
|
46
35
|
'Dump the active project (nodes + edges, with client-keys) as a manifest',
|
|
@@ -49,14 +38,14 @@ export const exportCommand = new Command('export')
|
|
|
49
38
|
.action(async (outputPath: string | undefined) => {
|
|
50
39
|
try {
|
|
51
40
|
const project = requireProject()
|
|
52
|
-
const root = await graphql<{ node: ServerNode | null }>(
|
|
41
|
+
const root = await graphql<{ node: ServerNode | null }>(EXPORT_PROJECT, {
|
|
53
42
|
id: project.id,
|
|
54
43
|
})
|
|
55
44
|
if (!root.node) {
|
|
56
45
|
throw new Error(`Active project ${project.id} not found.`)
|
|
57
46
|
}
|
|
58
47
|
const descendants = await graphql<{ descendants: ServerNode[] }>(
|
|
59
|
-
|
|
48
|
+
EXPORT_DESCENDANTS,
|
|
60
49
|
{ nodeId: project.id, relation: 'part_of', maxDepth: 100 },
|
|
61
50
|
)
|
|
62
51
|
|
|
@@ -94,7 +83,7 @@ export const exportCommand = new Command('export')
|
|
|
94
83
|
for (const relation of RELATIONS) {
|
|
95
84
|
const data = await graphql<{
|
|
96
85
|
edges: Array<{ id: string; metadata: string | null }>
|
|
97
|
-
}>(
|
|
86
|
+
}>(EXPORT_EDGES, { nodeId: sn.id, relation })
|
|
98
87
|
for (const target of data.edges) {
|
|
99
88
|
const targetKey =
|
|
100
89
|
keyById.get(target.id) ?? keyOf(target.id, target.metadata)
|