@sqaoss/flowy 1.7.0 → 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 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.7.0",
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
+ })
@@ -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
  })
@@ -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
- `mutation CreateCheckout($tier: String!) {
16
- createCheckout(tier: $tier) {
17
- url
18
- }
19
- }`,
18
+ CREATE_CHECKOUT,
20
19
  { tier: opts.tier },
21
20
  )
22
21
  output(data.createCheckout)
@@ -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 }>(PROJECT_QUERY, {
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
- DESCENDANTS_QUERY,
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
- }>(EDGES_QUERY, { nodeId: sn.id, relation })
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)