@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 +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 +36 -1
- package/src/commands/key.ts +9 -9
- package/src/commands/project.ts +23 -43
- package/src/commands/search.ts +7 -13
- package/src/commands/setup.test.ts +48 -1
- package/src/commands/setup.ts +2 -10
- 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 +34 -1
- package/src/commands/whoami.ts +8 -8
- package/src/util/config.test.ts +168 -1
- package/src/util/config.ts +188 -18
- package/src/util/operations.test.ts +114 -0
- package/src/util/operations.ts +331 -0
package/src/util/config.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
139
|
+
return stored
|
|
43
140
|
}
|
|
44
141
|
|
|
45
|
-
|
|
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(
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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
|