@sqaoss/flowy 1.7.0 → 1.9.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 +440 -0
- package/server/src/resolvers.test.ts +121 -1
- package/server/src/resolvers.ts +43 -8
- package/server/src/schema.ts +19 -1
- 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.test.ts +89 -1
- package/src/commands/tree.ts +11 -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 +332 -0
package/src/util/config.test.ts
CHANGED
|
@@ -52,7 +52,9 @@ describe('config', () => {
|
|
|
52
52
|
test('loadConfig returns defaults when no config file exists', async () => {
|
|
53
53
|
const { loadConfig } = await import('./config.ts')
|
|
54
54
|
const config = loadConfig()
|
|
55
|
-
|
|
55
|
+
// Canonical default mode is "remote" (was "saas" — kept as a back-compat
|
|
56
|
+
// alias on read only).
|
|
57
|
+
expect(config.mode).toBe('remote')
|
|
56
58
|
expect(config.apiUrl).toBe('https://flowy-ai.fly.dev/graphql')
|
|
57
59
|
expect(config.apiKey).toBe('')
|
|
58
60
|
expect(config.client.name).toBe('')
|
|
@@ -196,4 +198,169 @@ describe('config', () => {
|
|
|
196
198
|
(updated.projects[cwd] as { activeFeature?: string }).activeFeature,
|
|
197
199
|
).toBe('feat_999')
|
|
198
200
|
})
|
|
201
|
+
|
|
202
|
+
describe('per-mode profiles (F25)', () => {
|
|
203
|
+
test('default config canonicalizes mode to "remote"', async () => {
|
|
204
|
+
const { loadConfig } = await import('./config.ts')
|
|
205
|
+
const config = loadConfig()
|
|
206
|
+
// Canonical vocab is "remote"; "saas" is only a back-compat alias.
|
|
207
|
+
expect(config.mode).toBe('remote')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('local apiKey/projects do not bleed into remote mode', async () => {
|
|
211
|
+
const { saveConfig, loadConfig } = await import('./config.ts')
|
|
212
|
+
|
|
213
|
+
// Configure the local profile with a key + a project mapping.
|
|
214
|
+
const local = loadConfig()
|
|
215
|
+
local.mode = 'local'
|
|
216
|
+
local.apiKey = 'local-secret'
|
|
217
|
+
local.apiUrl = 'http://localhost:4000/graphql'
|
|
218
|
+
local.projects['/work/local'] = { id: 'proj_local', name: 'LocalProj' }
|
|
219
|
+
saveConfig(local)
|
|
220
|
+
|
|
221
|
+
// Switch to remote: the local key/projects must NOT be visible.
|
|
222
|
+
const remote = loadConfig()
|
|
223
|
+
remote.mode = 'remote'
|
|
224
|
+
expect(remote.apiKey).toBe('')
|
|
225
|
+
expect(remote.apiUrl).toBe('https://flowy-ai.fly.dev/graphql')
|
|
226
|
+
expect(remote.projects['/work/local']).toBeUndefined()
|
|
227
|
+
|
|
228
|
+
// Set a different key/project in remote mode and persist.
|
|
229
|
+
remote.apiKey = 'remote-secret'
|
|
230
|
+
remote.projects['/work/remote'] = {
|
|
231
|
+
id: 'proj_remote',
|
|
232
|
+
name: 'RemoteProj',
|
|
233
|
+
}
|
|
234
|
+
saveConfig(remote)
|
|
235
|
+
|
|
236
|
+
// Back to local: local data intact, remote data not visible.
|
|
237
|
+
const reloadLocal = loadConfig()
|
|
238
|
+
reloadLocal.mode = 'local'
|
|
239
|
+
expect(reloadLocal.apiKey).toBe('local-secret')
|
|
240
|
+
expect(reloadLocal.projects['/work/local']?.name).toBe('LocalProj')
|
|
241
|
+
expect(reloadLocal.projects['/work/remote']).toBeUndefined()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('getConfig reads from the active mode profile, not the other', async () => {
|
|
245
|
+
const { saveConfig, loadConfig, getConfig } = await import('./config.ts')
|
|
246
|
+
|
|
247
|
+
const local = loadConfig()
|
|
248
|
+
local.mode = 'local'
|
|
249
|
+
local.apiKey = 'local-secret'
|
|
250
|
+
local.apiUrl = 'http://localhost:4000/graphql'
|
|
251
|
+
saveConfig(local)
|
|
252
|
+
|
|
253
|
+
const remote = loadConfig()
|
|
254
|
+
remote.mode = 'remote'
|
|
255
|
+
remote.apiKey = 'remote-secret'
|
|
256
|
+
saveConfig(remote)
|
|
257
|
+
|
|
258
|
+
// Active mode is now remote (last saved). getConfig sees remote creds.
|
|
259
|
+
const cfg = getConfig()
|
|
260
|
+
expect(cfg.apiKey).toBe('remote-secret')
|
|
261
|
+
expect(cfg.apiUrl).toBe('https://flowy-ai.fly.dev/graphql')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('client name is shared across modes', async () => {
|
|
265
|
+
const { saveConfig, loadConfig } = await import('./config.ts')
|
|
266
|
+
const config = loadConfig()
|
|
267
|
+
config.client.name = 'Acme'
|
|
268
|
+
saveConfig(config)
|
|
269
|
+
const reloaded = loadConfig()
|
|
270
|
+
reloaded.mode = reloaded.mode === 'local' ? 'remote' : 'local'
|
|
271
|
+
expect(reloaded.client.name).toBe('Acme')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('migrates a legacy flat config into the active-mode profile', async () => {
|
|
275
|
+
const { loadConfig } = await import('./config.ts')
|
|
276
|
+
// Legacy shape written by an older CLI: flat apiKey/apiUrl/projects,
|
|
277
|
+
// mode="saas" (the old vocab).
|
|
278
|
+
writeFileSync(
|
|
279
|
+
CONFIG_PATH,
|
|
280
|
+
JSON.stringify({
|
|
281
|
+
mode: 'saas',
|
|
282
|
+
apiUrl: 'https://flowy-ai.fly.dev/graphql',
|
|
283
|
+
apiKey: 'legacy-key',
|
|
284
|
+
client: { name: 'Legacy Co' },
|
|
285
|
+
projects: { '/legacy/path': { id: 'p1', name: 'Legacy' } },
|
|
286
|
+
}),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
const config = loadConfig()
|
|
290
|
+
// "saas" canonicalizes to "remote".
|
|
291
|
+
expect(config.mode).toBe('remote')
|
|
292
|
+
// Flat fields land in the (remote) active profile.
|
|
293
|
+
expect(config.apiKey).toBe('legacy-key')
|
|
294
|
+
expect(config.projects['/legacy/path']?.name).toBe('Legacy')
|
|
295
|
+
expect(config.client.name).toBe('Legacy Co')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('resolveProject/resolveFeature use the active mode profile only', async () => {
|
|
299
|
+
const { saveConfig, loadConfig, resolveProject, resolveFeature } =
|
|
300
|
+
await import('./config.ts')
|
|
301
|
+
const cwd = process.cwd()
|
|
302
|
+
|
|
303
|
+
const local = loadConfig()
|
|
304
|
+
local.mode = 'local'
|
|
305
|
+
local.projects[cwd] = {
|
|
306
|
+
id: 'proj_local',
|
|
307
|
+
name: 'Local',
|
|
308
|
+
activeFeature: 'feat_local',
|
|
309
|
+
}
|
|
310
|
+
saveConfig(local)
|
|
311
|
+
|
|
312
|
+
const remote = loadConfig()
|
|
313
|
+
remote.mode = 'remote'
|
|
314
|
+
remote.projects[cwd] = {
|
|
315
|
+
id: 'proj_remote',
|
|
316
|
+
name: 'Remote',
|
|
317
|
+
activeFeature: 'feat_remote',
|
|
318
|
+
}
|
|
319
|
+
saveConfig(remote)
|
|
320
|
+
|
|
321
|
+
// Active mode is remote → resolution returns the remote project.
|
|
322
|
+
expect(resolveProject()?.id).toBe('proj_remote')
|
|
323
|
+
expect(resolveFeature()).toBe('feat_remote')
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('requireRemoteMode throws a coded error in local mode', async () => {
|
|
327
|
+
const { saveConfig, loadConfig, requireRemoteMode } = await import(
|
|
328
|
+
'./config.ts'
|
|
329
|
+
)
|
|
330
|
+
const config = loadConfig()
|
|
331
|
+
config.mode = 'local'
|
|
332
|
+
saveConfig(config)
|
|
333
|
+
|
|
334
|
+
expect(() => requireRemoteMode('whoami')).toThrow(/local mode/i)
|
|
335
|
+
try {
|
|
336
|
+
requireRemoteMode('whoami')
|
|
337
|
+
} catch (error) {
|
|
338
|
+
expect((error as { code?: string }).code).toBe('LOCAL_MODE')
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('requireRemoteMode is a no-op in remote mode', async () => {
|
|
343
|
+
const { saveConfig, loadConfig, requireRemoteMode } = await import(
|
|
344
|
+
'./config.ts'
|
|
345
|
+
)
|
|
346
|
+
const config = loadConfig()
|
|
347
|
+
config.mode = 'remote'
|
|
348
|
+
saveConfig(config)
|
|
349
|
+
expect(() => requireRemoteMode('whoami')).not.toThrow()
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
test('a half-written config (mode set, key not yet) still loads cleanly', async () => {
|
|
353
|
+
const { saveConfig, loadConfig } = await import('./config.ts')
|
|
354
|
+
// Simulate save-after-mode-switch but before the key arrives.
|
|
355
|
+
const config = loadConfig()
|
|
356
|
+
config.mode = 'remote'
|
|
357
|
+
saveConfig(config)
|
|
358
|
+
|
|
359
|
+
const reloaded = loadConfig()
|
|
360
|
+
expect(reloaded.mode).toBe('remote')
|
|
361
|
+
expect(reloaded.apiKey).toBe('')
|
|
362
|
+
expect(reloaded.apiUrl).toBe('https://flowy-ai.fly.dev/graphql')
|
|
363
|
+
expect(reloaded.projects).toEqual({})
|
|
364
|
+
})
|
|
365
|
+
})
|
|
199
366
|
})
|
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
|
+
})
|