@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/commands/task.ts
CHANGED
|
@@ -3,6 +3,19 @@ import { graphql } from '../util/client.ts'
|
|
|
3
3
|
import { requireFeature, resolveProject } from '../util/config.ts'
|
|
4
4
|
import { resolveDescription } from '../util/description.ts'
|
|
5
5
|
import { output, outputError } from '../util/format.ts'
|
|
6
|
+
import {
|
|
7
|
+
ALL_TASKS,
|
|
8
|
+
BLOCK_TASK,
|
|
9
|
+
CREATE_TASK,
|
|
10
|
+
DELETE_NODE,
|
|
11
|
+
LINK_TASK,
|
|
12
|
+
LIST_TASKS,
|
|
13
|
+
READY_TASKS,
|
|
14
|
+
SHOW_TASK,
|
|
15
|
+
TASK_DEPS,
|
|
16
|
+
UNBLOCK_TASK,
|
|
17
|
+
UPDATE_NODE,
|
|
18
|
+
} from '../util/operations.ts'
|
|
6
19
|
|
|
7
20
|
export const taskCommand = new Command('task').description(
|
|
8
21
|
'Manage tasks in the active feature',
|
|
@@ -27,23 +40,17 @@ taskCommand
|
|
|
27
40
|
description: opts.description,
|
|
28
41
|
descriptionFile: opts.descriptionFile,
|
|
29
42
|
})
|
|
30
|
-
const data = await graphql<{ createNode: { id: string } }>(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}`,
|
|
36
|
-
{ type: 'task', title: opts.title, description },
|
|
37
|
-
)
|
|
43
|
+
const data = await graphql<{ createNode: { id: string } }>(CREATE_TASK, {
|
|
44
|
+
type: 'task',
|
|
45
|
+
title: opts.title,
|
|
46
|
+
description,
|
|
47
|
+
})
|
|
38
48
|
const taskId = data.createNode.id
|
|
39
|
-
await graphql(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}`,
|
|
45
|
-
{ sourceId: taskId, targetId: featureId, relation: 'part_of' },
|
|
46
|
-
)
|
|
49
|
+
await graphql(LINK_TASK, {
|
|
50
|
+
sourceId: taskId,
|
|
51
|
+
targetId: featureId,
|
|
52
|
+
relation: 'part_of',
|
|
53
|
+
})
|
|
47
54
|
output(data.createNode)
|
|
48
55
|
} catch (error) {
|
|
49
56
|
outputError(error)
|
|
@@ -67,40 +74,27 @@ taskCommand
|
|
|
67
74
|
if (opts.ready) {
|
|
68
75
|
const projectId =
|
|
69
76
|
opts.project ?? (opts.all ? undefined : resolveProject()?.id)
|
|
70
|
-
const data = await graphql<{ readyTasks: unknown[] }>(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
id type title status createdAt
|
|
74
|
-
}
|
|
75
|
-
}`,
|
|
76
|
-
{ projectId: projectId ?? null },
|
|
77
|
-
)
|
|
77
|
+
const data = await graphql<{ readyTasks: unknown[] }>(READY_TASKS, {
|
|
78
|
+
projectId: projectId ?? null,
|
|
79
|
+
})
|
|
78
80
|
output(data.readyTasks)
|
|
79
81
|
return
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
if (opts.all) {
|
|
83
|
-
const data = await graphql<{ nodes: unknown[] }>(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
id type title status createdAt
|
|
87
|
-
}
|
|
88
|
-
}`,
|
|
89
|
-
{ type: 'task' },
|
|
90
|
-
)
|
|
85
|
+
const data = await graphql<{ nodes: unknown[] }>(ALL_TASKS, {
|
|
86
|
+
type: 'task',
|
|
87
|
+
})
|
|
91
88
|
output(data.nodes)
|
|
92
89
|
return
|
|
93
90
|
}
|
|
94
91
|
|
|
95
92
|
const featureId = requireFeature()
|
|
96
|
-
const data = await graphql<{ descendants: unknown[] }>(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}`,
|
|
102
|
-
{ nodeId: featureId, relation: 'part_of', maxDepth: 1 },
|
|
103
|
-
)
|
|
93
|
+
const data = await graphql<{ descendants: unknown[] }>(LIST_TASKS, {
|
|
94
|
+
nodeId: featureId,
|
|
95
|
+
relation: 'part_of',
|
|
96
|
+
maxDepth: 1,
|
|
97
|
+
})
|
|
104
98
|
const tasks = data.descendants.filter(
|
|
105
99
|
(n: unknown) => (n as { type: string }).type === 'task',
|
|
106
100
|
)
|
|
@@ -120,20 +114,7 @@ taskCommand
|
|
|
120
114
|
node: Record<string, unknown>
|
|
121
115
|
blockedBy: unknown[]
|
|
122
116
|
blocks: unknown[]
|
|
123
|
-
}>(
|
|
124
|
-
`query ShowTask($id: String!) {
|
|
125
|
-
node(id: $id) {
|
|
126
|
-
id type title description status metadata createdAt updatedAt
|
|
127
|
-
}
|
|
128
|
-
blockedBy: edges(nodeId: $id, relation: "blocks", direction: "incoming") {
|
|
129
|
-
id type title status
|
|
130
|
-
}
|
|
131
|
-
blocks: edges(nodeId: $id, relation: "blocks", direction: "outgoing") {
|
|
132
|
-
id type title status
|
|
133
|
-
}
|
|
134
|
-
}`,
|
|
135
|
-
{ id },
|
|
136
|
-
)
|
|
117
|
+
}>(SHOW_TASK, { id })
|
|
137
118
|
output({
|
|
138
119
|
...data.node,
|
|
139
120
|
blockedBy: data.blockedBy,
|
|
@@ -170,11 +151,7 @@ taskCommand
|
|
|
170
151
|
}
|
|
171
152
|
if (opts.metadata != null) variables.metadata = opts.metadata
|
|
172
153
|
const data = await graphql<{ updateNode: unknown }>(
|
|
173
|
-
|
|
174
|
-
updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
|
|
175
|
-
id type title description status metadata createdAt updatedAt
|
|
176
|
-
}
|
|
177
|
-
}`,
|
|
154
|
+
UPDATE_NODE,
|
|
178
155
|
variables,
|
|
179
156
|
)
|
|
180
157
|
output(data.updateNode)
|
|
@@ -189,12 +166,7 @@ taskCommand
|
|
|
189
166
|
.argument('<id>', 'Task ID')
|
|
190
167
|
.action(async (id: string) => {
|
|
191
168
|
try {
|
|
192
|
-
const data = await graphql<{ deleteNode: boolean }>(
|
|
193
|
-
`mutation DeleteNode($id: String!) {
|
|
194
|
-
deleteNode(id: $id)
|
|
195
|
-
}`,
|
|
196
|
-
{ id },
|
|
197
|
-
)
|
|
169
|
+
const data = await graphql<{ deleteNode: boolean }>(DELETE_NODE, { id })
|
|
198
170
|
output({ deleted: data.deleteNode })
|
|
199
171
|
} catch (error) {
|
|
200
172
|
outputError(error)
|
|
@@ -208,14 +180,11 @@ taskCommand
|
|
|
208
180
|
.argument('<id2>', 'Blocked task ID')
|
|
209
181
|
.action(async (id1: string, id2: string) => {
|
|
210
182
|
try {
|
|
211
|
-
const data = await graphql<{ createEdge: unknown }>(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}`,
|
|
217
|
-
{ sourceId: id1, targetId: id2, relation: 'blocks' },
|
|
218
|
-
)
|
|
183
|
+
const data = await graphql<{ createEdge: unknown }>(BLOCK_TASK, {
|
|
184
|
+
sourceId: id1,
|
|
185
|
+
targetId: id2,
|
|
186
|
+
relation: 'blocks',
|
|
187
|
+
})
|
|
219
188
|
output(data.createEdge)
|
|
220
189
|
} catch (error) {
|
|
221
190
|
outputError(error)
|
|
@@ -229,12 +198,11 @@ taskCommand
|
|
|
229
198
|
.argument('<id2>', 'Blocked task ID')
|
|
230
199
|
.action(async (id1: string, id2: string) => {
|
|
231
200
|
try {
|
|
232
|
-
const data = await graphql<{ removeEdge: boolean }>(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
)
|
|
201
|
+
const data = await graphql<{ removeEdge: boolean }>(UNBLOCK_TASK, {
|
|
202
|
+
sourceId: id1,
|
|
203
|
+
targetId: id2,
|
|
204
|
+
relation: 'blocks',
|
|
205
|
+
})
|
|
238
206
|
output({ removed: data.removeEdge })
|
|
239
207
|
} catch (error) {
|
|
240
208
|
outputError(error)
|
|
@@ -248,14 +216,7 @@ taskCommand
|
|
|
248
216
|
.action(async (id: string) => {
|
|
249
217
|
try {
|
|
250
218
|
const data = await graphql<{ blockedBy: unknown[]; blocks: unknown[] }>(
|
|
251
|
-
|
|
252
|
-
blockedBy: edges(nodeId: $id, relation: "blocks", direction: "incoming") {
|
|
253
|
-
id type title status
|
|
254
|
-
}
|
|
255
|
-
blocks: edges(nodeId: $id, relation: "blocks", direction: "outgoing") {
|
|
256
|
-
id type title status
|
|
257
|
-
}
|
|
258
|
-
}`,
|
|
219
|
+
TASK_DEPS,
|
|
259
220
|
{ id },
|
|
260
221
|
)
|
|
261
222
|
output({ id, blockedBy: data.blockedBy, blocks: data.blocks })
|
package/src/commands/tree.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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 { SUBTREE } from '../util/operations.ts'
|
|
4
5
|
|
|
5
6
|
export const treeCommand = new Command('tree')
|
|
6
7
|
.description('Show subtree from any entity')
|
|
@@ -8,14 +9,10 @@ export const treeCommand = new Command('tree')
|
|
|
8
9
|
.option('--depth <n>', 'Max depth', '10')
|
|
9
10
|
.action(async (id: string, opts) => {
|
|
10
11
|
try {
|
|
11
|
-
const data = await graphql<{ subtree: unknown[] }>(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
}`,
|
|
17
|
-
{ nodeId: id, maxDepth: Number.parseInt(opts.depth, 10) },
|
|
18
|
-
)
|
|
12
|
+
const data = await graphql<{ subtree: unknown[] }>(SUBTREE, {
|
|
13
|
+
nodeId: id,
|
|
14
|
+
maxDepth: Number.parseInt(opts.depth, 10),
|
|
15
|
+
})
|
|
19
16
|
output(data.subtree)
|
|
20
17
|
} catch (error) {
|
|
21
18
|
outputError(error)
|
|
@@ -9,7 +9,7 @@ beforeEach(() => {
|
|
|
9
9
|
mockOutput = vi.fn()
|
|
10
10
|
mockOutputError = vi.fn()
|
|
11
11
|
mockLoadConfig = vi.fn(() => ({
|
|
12
|
-
mode: '
|
|
12
|
+
mode: 'remote',
|
|
13
13
|
apiUrl: 'https://flowy-ai.fly.dev/graphql',
|
|
14
14
|
apiKey: 'flowy_secret_abcdef0123456789',
|
|
15
15
|
client: { name: '' },
|
|
@@ -42,6 +42,16 @@ beforeEach(() => {
|
|
|
42
42
|
return {
|
|
43
43
|
loadConfig: mockLoadConfig,
|
|
44
44
|
fingerprintKey: actual.fingerprintKey,
|
|
45
|
+
requireRemoteMode: (commandName: string) => {
|
|
46
|
+
const cfg = (mockLoadConfig as unknown as () => { mode: string })()
|
|
47
|
+
if (cfg.mode === 'local') {
|
|
48
|
+
const err = new Error(
|
|
49
|
+
`"flowy ${commandName}" is only available in remote mode. The active mode is local mode.`,
|
|
50
|
+
) as Error & { code?: string }
|
|
51
|
+
err.code = 'LOCAL_MODE'
|
|
52
|
+
throw err
|
|
53
|
+
}
|
|
54
|
+
},
|
|
45
55
|
}
|
|
46
56
|
})
|
|
47
57
|
})
|
|
@@ -100,4 +110,27 @@ describe('whoami command', () => {
|
|
|
100
110
|
const query = mockGraphql.mock.calls[0]?.[0] as string
|
|
101
111
|
expect(query).toContain('graceEndsAt')
|
|
102
112
|
})
|
|
113
|
+
|
|
114
|
+
test('whoami errors cleanly in local mode without hitting the server', async () => {
|
|
115
|
+
mockLoadConfig.mockReturnValue({
|
|
116
|
+
mode: 'local',
|
|
117
|
+
apiUrl: 'http://localhost:4000/graphql',
|
|
118
|
+
apiKey: '',
|
|
119
|
+
client: { name: '' },
|
|
120
|
+
projects: {},
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const { whoamiCommand } = await import('./whoami.ts')
|
|
124
|
+
await whoamiCommand.parseAsync([], { from: 'user' })
|
|
125
|
+
|
|
126
|
+
// No GraphQL call against the local server.
|
|
127
|
+
expect(mockGraphql).not.toHaveBeenCalled()
|
|
128
|
+
expect(mockOutput).not.toHaveBeenCalled()
|
|
129
|
+
expect(mockOutputError).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({
|
|
131
|
+
message: expect.stringMatching(/local mode/i),
|
|
132
|
+
code: 'LOCAL_MODE',
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
})
|
|
103
136
|
})
|
package/src/commands/whoami.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
fingerprintKey,
|
|
5
|
+
loadConfig,
|
|
6
|
+
requireRemoteMode,
|
|
7
|
+
} from '../util/config.ts'
|
|
4
8
|
import { output, outputError } from '../util/format.ts'
|
|
9
|
+
import { WHOAMI } from '../util/operations.ts'
|
|
5
10
|
|
|
6
11
|
export const whoamiCommand = new Command('whoami')
|
|
7
12
|
.description('Show current user info')
|
|
8
13
|
.action(async () => {
|
|
9
14
|
try {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
whoami {
|
|
13
|
-
id email tier createdAt graceEndsAt
|
|
14
|
-
}
|
|
15
|
-
}`,
|
|
16
|
-
)
|
|
15
|
+
requireRemoteMode('whoami')
|
|
16
|
+
const data = await graphql<{ whoami: Record<string, unknown> }>(WHOAMI)
|
|
17
17
|
// Surface a non-reversible fingerprint of the configured key so a human
|
|
18
18
|
// can confirm *which* credential is active without exposing it (F35).
|
|
19
19
|
output({
|
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
|
})
|