@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.
@@ -18,6 +18,32 @@ const DIR_MODE = 0o700
18
18
  const FILE_MODE = 0o600
19
19
  const isWindows = platform() === 'win32'
20
20
 
21
+ /** The two configuration profiles. `saas` is a back-compat alias for `remote`. */
22
+ export type Mode = 'local' | 'remote'
23
+
24
+ const LOCAL_API_URL = 'http://localhost:4000/graphql'
25
+ const REMOTE_API_URL = 'https://flowy-ai.fly.dev/graphql'
26
+
27
+ export interface ProjectConfig {
28
+ id: string
29
+ name: string
30
+ activeFeature?: string
31
+ }
32
+
33
+ /** Per-mode settings — isolated so local creds never bleed into remote. */
34
+ interface Profile {
35
+ apiUrl: string
36
+ apiKey: string
37
+ projects: Record<string, ProjectConfig>
38
+ }
39
+
40
+ /** On-disk shape (current). */
41
+ interface StoredConfig {
42
+ mode: Mode
43
+ client: { name: string }
44
+ profiles: Record<Mode, Profile>
45
+ }
46
+
21
47
  /**
22
48
  * Non-reversible fingerprint of an API key, safe to print to stdout/logs.
23
49
  * A short SHA-256 prefix lets a human confirm *which* key is configured
@@ -29,25 +55,157 @@ export function fingerprintKey(apiKey: string): string {
29
55
  return `sha256:${digest.slice(0, 12)}`
30
56
  }
31
57
 
32
- export function loadConfig() {
33
- if (!existsSync(CONFIG_PATH)) {
34
- return {
35
- mode: 'saas' as const,
36
- apiUrl: 'https://flowy-ai.fly.dev/graphql',
37
- apiKey: '',
38
- client: { name: '' },
39
- projects: {} as Record<string, unknown>,
58
+ /** Map any historical mode token onto the canonical vocabulary (F25). */
59
+ function canonicalMode(raw: unknown): Mode {
60
+ // "saas" was the old name for the hosted service; "remote" is canonical now.
61
+ return raw === 'local' ? 'local' : 'remote'
62
+ }
63
+
64
+ function defaultProfile(mode: Mode): Profile {
65
+ return {
66
+ apiUrl: mode === 'local' ? LOCAL_API_URL : REMOTE_API_URL,
67
+ apiKey: '',
68
+ projects: {},
69
+ }
70
+ }
71
+
72
+ function emptyStored(mode: Mode = 'remote'): StoredConfig {
73
+ return {
74
+ mode,
75
+ client: { name: '' },
76
+ profiles: {
77
+ local: defaultProfile('local'),
78
+ remote: defaultProfile('remote'),
79
+ },
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Read raw JSON off disk and normalize it into the current {mode, client,
85
+ * profiles} shape. Handles three eras gracefully:
86
+ * - missing file → defaults (remote mode, empty profiles)
87
+ * - legacy flat config → {mode, apiUrl, apiKey, projects} migrated into
88
+ * the active-mode profile
89
+ * - current profiled config
90
+ * The migration is non-destructive: an old user's key/projects land in the
91
+ * profile matching their (canonicalized) mode, so nothing is lost.
92
+ */
93
+ function readStored(): StoredConfig {
94
+ if (!existsSync(CONFIG_PATH)) return emptyStored()
95
+
96
+ let raw: Record<string, unknown>
97
+ try {
98
+ raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
99
+ } catch {
100
+ return emptyStored()
101
+ }
102
+
103
+ const mode = canonicalMode(raw.mode)
104
+ const clientName =
105
+ typeof (raw.client as { name?: unknown })?.name === 'string'
106
+ ? (raw.client as { name: string }).name
107
+ : ''
108
+
109
+ const stored = emptyStored(mode)
110
+ stored.client.name = clientName
111
+
112
+ if (raw.profiles && typeof raw.profiles === 'object') {
113
+ // Current profiled shape — merge each known profile over the defaults.
114
+ const profiles = raw.profiles as Partial<Record<string, Partial<Profile>>>
115
+ // Fold a legacy "saas" profile onto "remote" if one is ever present.
116
+ for (const key of ['local', 'remote', 'saas'] as const) {
117
+ const p = profiles[key]
118
+ if (!p) continue
119
+ const target: Mode = key === 'saas' ? 'remote' : key
120
+ stored.profiles[target] = {
121
+ apiUrl:
122
+ typeof p.apiUrl === 'string'
123
+ ? p.apiUrl
124
+ : defaultProfile(target).apiUrl,
125
+ apiKey: typeof p.apiKey === 'string' ? p.apiKey : '',
126
+ projects: (p.projects as Record<string, ProjectConfig>) ?? {},
127
+ }
40
128
  }
129
+ return stored
130
+ }
131
+
132
+ // Legacy flat config — migrate apiUrl/apiKey/projects into the active profile.
133
+ const profile = stored.profiles[mode]
134
+ if (typeof raw.apiUrl === 'string') profile.apiUrl = raw.apiUrl
135
+ if (typeof raw.apiKey === 'string') profile.apiKey = raw.apiKey
136
+ if (raw.projects && typeof raw.projects === 'object') {
137
+ profile.projects = raw.projects as Record<string, ProjectConfig>
41
138
  }
42
- return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
139
+ return stored
43
140
  }
44
141
 
45
- export function saveConfig(config: ReturnType<typeof loadConfig>): void {
142
+ /**
143
+ * A live, backward-compatible view over the stored config. Reading/writing
144
+ * `apiUrl`/`apiKey`/`projects` transparently targets the *active* mode's
145
+ * profile, so every existing command keeps working unchanged. Reassigning
146
+ * `mode` re-points those accessors at the other profile — the mechanism that
147
+ * keeps local and remote credentials from cross-contaminating (F25).
148
+ */
149
+ export interface Config {
150
+ mode: Mode
151
+ client: { name: string }
152
+ apiUrl: string
153
+ apiKey: string
154
+ projects: Record<string, ProjectConfig>
155
+ /** @internal — the underlying per-mode storage, persisted by saveConfig. */
156
+ readonly profiles: Record<Mode, Profile>
157
+ }
158
+
159
+ function makeConfig(stored: StoredConfig): Config {
160
+ let mode = stored.mode
161
+ const view = {
162
+ client: stored.client,
163
+ get profiles() {
164
+ return stored.profiles
165
+ },
166
+ get mode() {
167
+ return mode
168
+ },
169
+ set mode(next: Mode) {
170
+ mode = canonicalMode(next)
171
+ },
172
+ get apiUrl() {
173
+ return stored.profiles[mode].apiUrl
174
+ },
175
+ set apiUrl(value: string) {
176
+ stored.profiles[mode].apiUrl = value
177
+ },
178
+ get apiKey() {
179
+ return stored.profiles[mode].apiKey
180
+ },
181
+ set apiKey(value: string) {
182
+ stored.profiles[mode].apiKey = value
183
+ },
184
+ get projects() {
185
+ return stored.profiles[mode].projects
186
+ },
187
+ set projects(value: Record<string, ProjectConfig>) {
188
+ stored.profiles[mode].projects = value
189
+ },
190
+ }
191
+ return view as Config
192
+ }
193
+
194
+ export function loadConfig(): Config {
195
+ return makeConfig(readStored())
196
+ }
197
+
198
+ export function saveConfig(config: Config): void {
199
+ const stored: StoredConfig = {
200
+ mode: canonicalMode(config.mode),
201
+ client: { name: config.client.name },
202
+ profiles: config.profiles,
203
+ }
46
204
  mkdirSync(CONFIG_DIR, { recursive: true, mode: DIR_MODE })
47
205
  // `mode` on writeFileSync only applies to *newly created* files and is
48
206
  // masked by umask, so an explicit chmod afterward both corrects a
49
207
  // pre-existing world-readable (0644) config and survives a tight umask.
50
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), {
208
+ writeFileSync(CONFIG_PATH, JSON.stringify(stored, null, 2), {
51
209
  mode: FILE_MODE,
52
210
  })
53
211
  if (!isWindows) {
@@ -56,10 +214,22 @@ export function saveConfig(config: ReturnType<typeof loadConfig>): void {
56
214
  }
57
215
  }
58
216
 
59
- export interface ProjectConfig {
60
- id: string
61
- name: string
62
- activeFeature?: string
217
+ /**
218
+ * Guard SaaS-only commands. The bundled local server has no whoami/billing/key
219
+ * endpoints, so running them in local mode used to fail with an obscure
220
+ * GraphQL error. Fail fast with a clear message + a distinct code (F25).
221
+ */
222
+ export function requireRemoteMode(commandName: string): void {
223
+ const { mode } = loadConfig()
224
+ if (mode === 'local') {
225
+ const err = new Error(
226
+ `"flowy ${commandName}" is only available in remote mode. ` +
227
+ `The active mode is local mode — run "flowy setup remote" to connect ` +
228
+ `to the hosted Flowy service.`,
229
+ ) as Error & { code?: string }
230
+ err.code = 'LOCAL_MODE'
231
+ throw err
232
+ }
63
233
  }
64
234
 
65
235
  export function resolveProject(): ProjectConfig | null {
@@ -69,7 +239,7 @@ export function resolveProject(): ProjectConfig | null {
69
239
  if (envProject) {
70
240
  return (
71
241
  (Object.values(config.projects).find(
72
- (p) => (p as ProjectConfig).name === envProject,
242
+ (p) => p.name === envProject,
73
243
  ) as ProjectConfig) ?? null
74
244
  )
75
245
  }
@@ -83,7 +253,7 @@ export function resolveProject(): ProjectConfig | null {
83
253
  (cwd === path || cwd.startsWith(`${path}/`)) &&
84
254
  path.length > bestLength
85
255
  ) {
86
- bestMatch = project as ProjectConfig
256
+ bestMatch = project
87
257
  bestLength = path.length
88
258
  }
89
259
  }
@@ -126,7 +296,7 @@ export function updateProjectConfig(
126
296
 
127
297
  for (const [path, project] of Object.entries(config.projects)) {
128
298
  if (cwd === path || cwd.startsWith(`${path}/`)) {
129
- updater(project as ProjectConfig)
299
+ updater(project)
130
300
  saveConfig(config)
131
301
  return
132
302
  }
@@ -0,0 +1,114 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { describe, expect, it } from 'vitest'
4
+ import {
5
+ LOCAL_CONTRACT_OPERATIONS,
6
+ SAAS_ONLY_OPERATIONS,
7
+ } from './operations.ts'
8
+
9
+ /**
10
+ * Root-suite half of the CLI/local-server contract guard (P1-1).
11
+ *
12
+ * The executable half lives in `server/src/contract.test.ts` (runs the ops
13
+ * against a live local server). This half runs under the root `bun run test`
14
+ * and guards the *single source of truth*: every command must send a canonical
15
+ * operation from `operations.ts`, and the two op-sets must stay disjoint and
16
+ * well-formed. If a command re-inlines a query string (bypassing the contract),
17
+ * this fails.
18
+ */
19
+
20
+ const allOps = {
21
+ ...LOCAL_CONTRACT_OPERATIONS,
22
+ ...SAAS_ONLY_OPERATIONS,
23
+ } as Record<string, string>
24
+
25
+ describe('operations module', () => {
26
+ it('every operation is a non-empty named query or mutation', () => {
27
+ for (const [name, op] of Object.entries(allOps)) {
28
+ expect(op.trim().length, name).toBeGreaterThan(0)
29
+ expect(op, name).toMatch(/^\s*(query|mutation)\s/)
30
+ }
31
+ })
32
+
33
+ it('local and SaaS-only operation sets are disjoint', () => {
34
+ const local = new Set(Object.keys(LOCAL_CONTRACT_OPERATIONS))
35
+ const saas = Object.keys(SAAS_ONLY_OPERATIONS)
36
+ for (const name of saas) expect(local.has(name), name).toBe(false)
37
+ })
38
+
39
+ it('SaaS-only set is exactly the documented divergences', () => {
40
+ // These are deliberately NOT served by the bundled local server.
41
+ expect(new Set(Object.keys(SAAS_ONLY_OPERATIONS))).toEqual(
42
+ new Set(['REGISTER', 'WHOAMI', 'ROTATE_API_KEY', 'CREATE_CHECKOUT']),
43
+ )
44
+ })
45
+ })
46
+
47
+ describe('commands send canonical operations (no re-inlined queries)', () => {
48
+ // command file -> the operation constants it must import from operations.ts
49
+ const expectations: Record<string, string[]> = {
50
+ 'project.ts': [
51
+ 'CREATE_PROJECT',
52
+ 'LIST_PROJECTS_FOR_SET',
53
+ 'LIST_PROJECTS',
54
+ 'GET_PROJECT',
55
+ 'UPDATE_NODE',
56
+ 'DELETE_NODE',
57
+ ],
58
+ 'feature.ts': [
59
+ 'CREATE_NODE',
60
+ 'CREATE_EDGE',
61
+ 'DESCENDANTS',
62
+ 'DESCENDANTS_BRIEF',
63
+ 'UPDATE_NODE',
64
+ 'DELETE_NODE',
65
+ 'GET_NODE',
66
+ ],
67
+ 'task.ts': [
68
+ 'CREATE_TASK',
69
+ 'LINK_TASK',
70
+ 'READY_TASKS',
71
+ 'ALL_TASKS',
72
+ 'LIST_TASKS',
73
+ 'SHOW_TASK',
74
+ 'UPDATE_NODE',
75
+ 'DELETE_NODE',
76
+ 'BLOCK_TASK',
77
+ 'UNBLOCK_TASK',
78
+ 'TASK_DEPS',
79
+ ],
80
+ 'status.ts': ['UPDATE_STATUS'],
81
+ 'approve.ts': ['APPROVE_NODE'],
82
+ 'search.ts': ['SEARCH'],
83
+ 'tree.ts': ['SUBTREE'],
84
+ 'whoami.ts': ['WHOAMI'],
85
+ 'billing.ts': ['CREATE_CHECKOUT'],
86
+ 'key.ts': ['ROTATE_API_KEY'],
87
+ 'init.ts': ['CREATE_PROJECT'],
88
+ 'setup.ts': ['REGISTER'],
89
+ 'import.ts': [
90
+ 'IMPORT_EXISTING',
91
+ 'IMPORT_EDGES',
92
+ 'IMPORT_CREATE',
93
+ 'IMPORT_UPDATE',
94
+ 'IMPORT_EDGE',
95
+ ],
96
+ 'export.ts': ['EXPORT_PROJECT', 'EXPORT_DESCENDANTS', 'EXPORT_EDGES'],
97
+ }
98
+
99
+ for (const [file, ops] of Object.entries(expectations)) {
100
+ it(`${file} imports its operations from operations.ts`, () => {
101
+ const path = fileURLToPath(
102
+ new URL(`../commands/${file}`, import.meta.url),
103
+ )
104
+ const source = readFileSync(path, 'utf-8')
105
+ expect(source).toContain("from '../util/operations.ts'")
106
+ for (const op of ops) expect(source, `${file} -> ${op}`).toContain(op)
107
+ // A command file must not inline GraphQL operation text anymore: the
108
+ // backtick-wrapped query/mutation lives only in operations.ts.
109
+ expect(source, `${file} re-inlines a GraphQL operation`).not.toMatch(
110
+ /`\s*(query|mutation)\s/,
111
+ )
112
+ })
113
+ }
114
+ })
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Canonical GraphQL operations the Flowy CLI sends.
3
+ *
4
+ * Every operation the CLI relies on lives here, copied verbatim from the
5
+ * command that issues it. Commands import these constants instead of inlining
6
+ * the query/mutation text, so there is a single source of truth for the
7
+ * contract the CLI expects a backend to satisfy.
8
+ *
9
+ * Two consumers share this module:
10
+ * 1. `src/commands/*.ts` — the runtime CLI commands.
11
+ * 2. `server/src/contract.test.ts` — the contract guard that executes each
12
+ * operation against the bundled local server and asserts it is satisfied.
13
+ *
14
+ * If the bundled local server (or the SaaS server) renames an operation, a
15
+ * field, or an argument the CLI uses, the contract test fails — catching drift
16
+ * before it ships. See `server/src/contract.test.ts` for the documented list
17
+ * of intentional local/SaaS divergences (SaaS-only `whoami`, `register`,
18
+ * `rotateApiKey`, `createCheckout`, `auditLog`, `ancestors`).
19
+ */
20
+
21
+ // --- Nodes: read --------------------------------------------------------------
22
+
23
+ /** project.ts `show`, feature.ts `show` — fetch a single node by id. */
24
+ export const GET_NODE = `query GetNode($id: String!) {
25
+ node(id: $id) {
26
+ id type title description status metadata createdAt updatedAt
27
+ }
28
+ }`
29
+
30
+ /** project.ts `show` — same shape as GET_NODE, distinct operation name. */
31
+ export const GET_PROJECT = `query GetProject($id: String!) {
32
+ node(id: $id) {
33
+ id type title description status metadata createdAt updatedAt
34
+ }
35
+ }`
36
+
37
+ /** project.ts `set` — list nodes of a type, minimal fields for name matching. */
38
+ export const LIST_PROJECTS_FOR_SET = `query ListProjects($type: String) {
39
+ nodes(type: $type) {
40
+ id title
41
+ }
42
+ }`
43
+
44
+ /** project.ts `list` — list nodes of a type with display fields. */
45
+ export const LIST_PROJECTS = `query ListProjects($type: String) {
46
+ nodes(type: $type) {
47
+ id type title description status createdAt updatedAt
48
+ }
49
+ }`
50
+
51
+ /** task.ts `list --all` — every node of a type. */
52
+ export const ALL_TASKS = `query AllTasks($type: String!) {
53
+ nodes(type: $type) {
54
+ id type title status createdAt
55
+ }
56
+ }`
57
+
58
+ /** feature.ts `list` — direct children via relation, full display fields. */
59
+ export const DESCENDANTS = `query Descendants($nodeId: String!, $relation: String, $maxDepth: Int) {
60
+ descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
61
+ id type title description status createdAt updatedAt
62
+ }
63
+ }`
64
+
65
+ /** feature.ts `set` — direct children via relation, brief fields for matching. */
66
+ export const DESCENDANTS_BRIEF = `query Descendants($nodeId: String!, $relation: String, $maxDepth: Int) {
67
+ descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
68
+ id type title status
69
+ }
70
+ }`
71
+
72
+ /** task.ts `list` (active feature) — children with status only. */
73
+ export const LIST_TASKS = `query ListTasks($nodeId: String!, $relation: String!, $maxDepth: Int) {
74
+ descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
75
+ id type title status createdAt
76
+ }
77
+ }`
78
+
79
+ /** tree.ts — full subtree from any root. */
80
+ export const SUBTREE = `query Subtree($nodeId: String!, $maxDepth: Int) {
81
+ subtree(nodeId: $nodeId, maxDepth: $maxDepth) {
82
+ id type title status
83
+ }
84
+ }`
85
+
86
+ /** task.ts `list --ready` — actionable tasks (server-side dependency logic). */
87
+ export const READY_TASKS = `query ReadyTasks($projectId: String) {
88
+ readyTasks(projectId: $projectId) {
89
+ id type title status createdAt
90
+ }
91
+ }`
92
+
93
+ /** task.ts `show` — node plus its incoming/outgoing `blocks` edges. */
94
+ export const SHOW_TASK = `query ShowTask($id: String!) {
95
+ node(id: $id) {
96
+ id type title description status metadata createdAt updatedAt
97
+ }
98
+ blockedBy: edges(nodeId: $id, relation: "blocks", direction: "incoming") {
99
+ id type title status
100
+ }
101
+ blocks: edges(nodeId: $id, relation: "blocks", direction: "outgoing") {
102
+ id type title status
103
+ }
104
+ }`
105
+
106
+ /** task.ts `deps` — incoming/outgoing `blocks` edges only. */
107
+ export const TASK_DEPS = `query TaskDeps($id: String!) {
108
+ blockedBy: edges(nodeId: $id, relation: "blocks", direction: "incoming") {
109
+ id type title status
110
+ }
111
+ blocks: edges(nodeId: $id, relation: "blocks", direction: "outgoing") {
112
+ id type title status
113
+ }
114
+ }`
115
+
116
+ /** search.ts — full-text search with optional type/status/limit filters. */
117
+ export const SEARCH = `query Search($query: String!, $type: String, $status: String, $limit: Int) {
118
+ search(query: $query, type: $type, status: $status, limit: $limit) {
119
+ id type title description status
120
+ }
121
+ }`
122
+
123
+ // --- Nodes: write -------------------------------------------------------------
124
+
125
+ /** project.ts `create`, init.ts — create a node by type/title. */
126
+ export const CREATE_PROJECT = `mutation CreateProject($type: String!, $title: String!) {
127
+ createNode(type: $type, title: $title) {
128
+ id type title description status metadata createdAt updatedAt
129
+ }
130
+ }`
131
+
132
+ /** feature.ts `create` — create a node with a description. */
133
+ export const CREATE_NODE = `mutation CreateNode($type: String!, $title: String!, $description: String) {
134
+ createNode(type: $type, title: $title, description: $description) {
135
+ id type title description status createdAt updatedAt
136
+ }
137
+ }`
138
+
139
+ /** task.ts `create` — create a task node. */
140
+ export const CREATE_TASK = `mutation CreateTask($type: String!, $title: String!, $description: String) {
141
+ createNode(type: $type, title: $title, description: $description) {
142
+ id type title description status createdAt
143
+ }
144
+ }`
145
+
146
+ /** project/feature/task `update` — title/description/metadata. */
147
+ export const UPDATE_NODE = `mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
148
+ updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
149
+ id type title description status metadata createdAt updatedAt
150
+ }
151
+ }`
152
+
153
+ /** status.ts — status-only shorthand update. */
154
+ export const UPDATE_STATUS = `mutation UpdateStatus($id: String!, $status: String) {
155
+ updateNode(id: $id, status: $status) {
156
+ id type title status updatedAt
157
+ }
158
+ }`
159
+
160
+ /** approve.ts — promote a pending_review node to approved. */
161
+ export const APPROVE_NODE = `mutation ApproveNode($id: String!) {
162
+ approveNode(id: $id) { id type title status updatedAt }
163
+ }`
164
+
165
+ /** project/feature/task `delete` — remove a node (and its edges). */
166
+ export const DELETE_NODE = `mutation DeleteNode($id: String!) {
167
+ deleteNode(id: $id)
168
+ }`
169
+
170
+ // --- Edges --------------------------------------------------------------------
171
+
172
+ /** feature.ts `create` — link feature under project. */
173
+ export const CREATE_EDGE = `mutation CreateEdge($sourceId: String!, $targetId: String!, $relation: String!) {
174
+ createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
175
+ sourceId targetId relation createdAt
176
+ }
177
+ }`
178
+
179
+ /** task.ts `create` — link task under feature (no createdAt selected). */
180
+ export const LINK_TASK = `mutation LinkTask($sourceId: String!, $targetId: String!, $relation: String!) {
181
+ createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
182
+ sourceId targetId relation
183
+ }
184
+ }`
185
+
186
+ /** task.ts `block` — create a `blocks` edge between two tasks. */
187
+ export const BLOCK_TASK = `mutation BlockTask($sourceId: String!, $targetId: String!, $relation: String!) {
188
+ createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
189
+ sourceId targetId relation createdAt
190
+ }
191
+ }`
192
+
193
+ /** task.ts `unblock` — remove a `blocks` edge. */
194
+ export const UNBLOCK_TASK = `mutation UnblockTask($sourceId: String!, $targetId: String!, $relation: String!) {
195
+ removeEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation)
196
+ }`
197
+
198
+ // --- Import / export ----------------------------------------------------------
199
+
200
+ /** import.ts — read existing nodes of a type to dedup by client-key. */
201
+ export const IMPORT_EXISTING = `query ImportExisting($type: String) {
202
+ nodes(type: $type) { id type title metadata }
203
+ }`
204
+
205
+ /** import.ts — outgoing edges of a node, used to dedup edge creation. */
206
+ export const IMPORT_EDGES = `query ImportEdges($nodeId: String!, $relation: String!) {
207
+ edges(nodeId: $nodeId, relation: $relation, direction: "outgoing") { id }
208
+ }`
209
+
210
+ /** import.ts — create a node (full arg surface incl. status/metadata). */
211
+ export const IMPORT_CREATE = `mutation ImportCreate($type: String!, $title: String!, $description: String, $status: String, $metadata: String) {
212
+ createNode(type: $type, title: $title, description: $description, status: $status, metadata: $metadata) { id }
213
+ }`
214
+
215
+ /** import.ts — update a node (full arg surface incl. status/metadata). */
216
+ export const IMPORT_UPDATE = `mutation ImportUpdate($id: String!, $title: String, $description: String, $status: String, $metadata: String) {
217
+ updateNode(id: $id, title: $title, description: $description, status: $status, metadata: $metadata) { id }
218
+ }`
219
+
220
+ /** import.ts — create an edge during materialization. */
221
+ export const IMPORT_EDGE = `mutation ImportEdge($sourceId: String!, $targetId: String!, $relation: String!) {
222
+ createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) { sourceId targetId relation }
223
+ }`
224
+
225
+ /** export.ts — fetch the root project node. */
226
+ export const EXPORT_PROJECT = `query ExportProject($id: String!) {
227
+ node(id: $id) { id type title description status metadata }
228
+ }`
229
+
230
+ /** export.ts — all descendants of the project for the dump. */
231
+ export const EXPORT_DESCENDANTS = `query ExportDescendants($nodeId: String!, $relation: String, $maxDepth: Int) {
232
+ descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
233
+ id type title description status metadata
234
+ }
235
+ }`
236
+
237
+ /** export.ts — outgoing edges of a node, read back through the edge model. */
238
+ export const EXPORT_EDGES = `query ExportEdges($nodeId: String!, $relation: String!) {
239
+ edges(nodeId: $nodeId, relation: $relation, direction: "outgoing") {
240
+ id metadata
241
+ }
242
+ }`
243
+
244
+ // --- SaaS-only operations (NOT served by the bundled local server) ------------
245
+ //
246
+ // These belong to the hosted `flowy-saas` backend (auth, billing, audit). The
247
+ // bundled local server intentionally does not implement them — see the
248
+ // divergence list in `server/src/contract.test.ts`. They are exported here so
249
+ // the CLI commands share the same single source of truth and the SaaS contract
250
+ // test (flowy-saas `test/helpers/cli-queries.ts`) can mirror them.
251
+
252
+ /** setup.ts remote — register a hosted account. */
253
+ export const REGISTER = `mutation Register($email: String!, $tier: String) {
254
+ register(email: $email, tier: $tier) {
255
+ user { id email tier createdAt graceEndsAt }
256
+ apiKey
257
+ checkoutUrl
258
+ }
259
+ }`
260
+
261
+ /** whoami.ts — current hosted user. */
262
+ export const WHOAMI = `query Whoami {
263
+ whoami {
264
+ id email tier createdAt graceEndsAt
265
+ }
266
+ }`
267
+
268
+ /** key.ts — rotate the hosted API key. */
269
+ export const ROTATE_API_KEY = `mutation RotateApiKey {
270
+ rotateApiKey {
271
+ user { id email tier createdAt graceEndsAt }
272
+ apiKey
273
+ }
274
+ }`
275
+
276
+ /** billing.ts — create a checkout session for a tier. */
277
+ export const CREATE_CHECKOUT = `mutation CreateCheckout($tier: String!) {
278
+ createCheckout(tier: $tier) {
279
+ url
280
+ }
281
+ }`
282
+
283
+ /**
284
+ * Operations the bundled local server is contractually required to satisfy.
285
+ * The contract test executes each of these against a live local server.
286
+ */
287
+ export const LOCAL_CONTRACT_OPERATIONS = {
288
+ GET_NODE,
289
+ GET_PROJECT,
290
+ LIST_PROJECTS_FOR_SET,
291
+ LIST_PROJECTS,
292
+ ALL_TASKS,
293
+ DESCENDANTS,
294
+ DESCENDANTS_BRIEF,
295
+ LIST_TASKS,
296
+ SUBTREE,
297
+ READY_TASKS,
298
+ SHOW_TASK,
299
+ TASK_DEPS,
300
+ SEARCH,
301
+ CREATE_PROJECT,
302
+ CREATE_NODE,
303
+ CREATE_TASK,
304
+ UPDATE_NODE,
305
+ UPDATE_STATUS,
306
+ APPROVE_NODE,
307
+ DELETE_NODE,
308
+ CREATE_EDGE,
309
+ LINK_TASK,
310
+ BLOCK_TASK,
311
+ UNBLOCK_TASK,
312
+ IMPORT_EXISTING,
313
+ IMPORT_EDGES,
314
+ IMPORT_CREATE,
315
+ IMPORT_UPDATE,
316
+ IMPORT_EDGE,
317
+ EXPORT_PROJECT,
318
+ EXPORT_DESCENDANTS,
319
+ EXPORT_EDGES,
320
+ } as const
321
+
322
+ /**
323
+ * SaaS-only operations the bundled local server intentionally does NOT serve.
324
+ * Documented here so the divergence is explicit and discoverable.
325
+ */
326
+ export const SAAS_ONLY_OPERATIONS = {
327
+ REGISTER,
328
+ WHOAMI,
329
+ ROTATE_API_KEY,
330
+ CREATE_CHECKOUT,
331
+ } as const