@sqaoss/flowy 1.5.0 → 1.6.1
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 +115 -33
- package/package.json +1 -1
- package/skills/using-flowy/SKILL.md +127 -55
- package/src/commands/export.test.ts +242 -0
- package/src/commands/export.ts +130 -0
- package/src/commands/import.test.ts +287 -0
- package/src/commands/import.ts +181 -0
- package/src/commands/setup.test.ts +90 -5
- package/src/commands/setup.ts +33 -15
- package/src/index.test.ts +23 -0
- package/src/index.ts +4 -0
- package/src/util/manifest.test.ts +159 -0
- package/src/util/manifest.ts +192 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { Command } from 'commander'
|
|
3
|
+
import { graphql } from '../util/client.ts'
|
|
4
|
+
import { output, outputError } from '../util/format.ts'
|
|
5
|
+
import {
|
|
6
|
+
buildNodeMetadata,
|
|
7
|
+
type Manifest,
|
|
8
|
+
type ManifestNode,
|
|
9
|
+
parseManifest,
|
|
10
|
+
readClientKey,
|
|
11
|
+
} from '../util/manifest.ts'
|
|
12
|
+
|
|
13
|
+
const NODE_TYPES = ['project', 'feature', 'task'] as const
|
|
14
|
+
|
|
15
|
+
/** Relations import materializes; existing-edge dedup queries each of these. */
|
|
16
|
+
const RELATIONS = ['part_of', 'blocks'] as const
|
|
17
|
+
|
|
18
|
+
interface ServerNode {
|
|
19
|
+
id: string
|
|
20
|
+
type: string
|
|
21
|
+
title: string
|
|
22
|
+
metadata: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface DesiredEdge {
|
|
26
|
+
sourceKey: string
|
|
27
|
+
targetKey: string
|
|
28
|
+
relation: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function edgeKey(source: string, target: string, relation: string): string {
|
|
32
|
+
return `${source}|${target}|${relation}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** All edges the manifest implies: implicit `part_of` from `parent` + explicit edges. */
|
|
36
|
+
function desiredEdges(manifest: Manifest): DesiredEdge[] {
|
|
37
|
+
const edges: DesiredEdge[] = []
|
|
38
|
+
const seen = new Set<string>()
|
|
39
|
+
const add = (sourceKey: string, targetKey: string, relation: string) => {
|
|
40
|
+
const k = edgeKey(sourceKey, targetKey, relation)
|
|
41
|
+
if (seen.has(k)) return
|
|
42
|
+
seen.add(k)
|
|
43
|
+
edges.push({ sourceKey, targetKey, relation })
|
|
44
|
+
}
|
|
45
|
+
for (const node of manifest.nodes) {
|
|
46
|
+
if (node.parent) add(node.key, node.parent, 'part_of')
|
|
47
|
+
}
|
|
48
|
+
for (const e of manifest.edges) add(e.source, e.target, e.relation)
|
|
49
|
+
return edges
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Read every existing node, mapping its stored client-key to its server id. */
|
|
53
|
+
async function loadExisting(): Promise<Map<string, string>> {
|
|
54
|
+
const idByKey = new Map<string, string>()
|
|
55
|
+
for (const type of NODE_TYPES) {
|
|
56
|
+
const data = await graphql<{ nodes: ServerNode[] }>(
|
|
57
|
+
`query ImportExisting($type: String) {
|
|
58
|
+
nodes(type: $type) { id type title metadata }
|
|
59
|
+
}`,
|
|
60
|
+
{ type },
|
|
61
|
+
)
|
|
62
|
+
for (const node of data.nodes) {
|
|
63
|
+
const key = readClientKey(node.metadata)
|
|
64
|
+
if (key) idByKey.set(key, node.id)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return idByKey
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Collect the edges that already exist for the given nodes, as a set of
|
|
72
|
+
* `<sourceId>|<targetId>|<relation>` triples. Read back through the real edge
|
|
73
|
+
* model (`Query.edges`), so externally-created edges (e.g. `task block`) are
|
|
74
|
+
* recognized and never duplicated.
|
|
75
|
+
*/
|
|
76
|
+
async function loadExistingEdges(nodeIds: string[]): Promise<Set<string>> {
|
|
77
|
+
const existing = new Set<string>()
|
|
78
|
+
for (const nodeId of nodeIds) {
|
|
79
|
+
for (const relation of RELATIONS) {
|
|
80
|
+
const data = await graphql<{ edges: Array<{ id: string }> }>(
|
|
81
|
+
`query ImportEdges($nodeId: String!, $relation: String!) {
|
|
82
|
+
edges(nodeId: $nodeId, relation: $relation, direction: "outgoing") { id }
|
|
83
|
+
}`,
|
|
84
|
+
{ nodeId, relation },
|
|
85
|
+
)
|
|
86
|
+
for (const target of data.edges) {
|
|
87
|
+
existing.add(edgeKey(nodeId, target.id, relation))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return existing
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const CREATE_NODE = `mutation ImportCreate($type: String!, $title: String!, $description: String, $status: String, $metadata: String) {
|
|
95
|
+
createNode(type: $type, title: $title, description: $description, status: $status, metadata: $metadata) { id }
|
|
96
|
+
}`
|
|
97
|
+
|
|
98
|
+
const UPDATE_NODE = `mutation ImportUpdate($id: String!, $title: String, $description: String, $status: String, $metadata: String) {
|
|
99
|
+
updateNode(id: $id, title: $title, description: $description, status: $status, metadata: $metadata) { id }
|
|
100
|
+
}`
|
|
101
|
+
|
|
102
|
+
const CREATE_EDGE = `mutation ImportEdge($sourceId: String!, $targetId: String!, $relation: String!) {
|
|
103
|
+
createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) { sourceId targetId relation }
|
|
104
|
+
}`
|
|
105
|
+
|
|
106
|
+
async function upsertNode(
|
|
107
|
+
node: ManifestNode,
|
|
108
|
+
existingId: string | undefined,
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
const metadata = buildNodeMetadata(node.key, node.metadata)
|
|
111
|
+
if (existingId) {
|
|
112
|
+
await graphql<{ updateNode: { id: string } }>(UPDATE_NODE, {
|
|
113
|
+
id: existingId,
|
|
114
|
+
title: node.title,
|
|
115
|
+
description: node.description ?? null,
|
|
116
|
+
status: node.status ?? null,
|
|
117
|
+
metadata,
|
|
118
|
+
})
|
|
119
|
+
return existingId
|
|
120
|
+
}
|
|
121
|
+
const data = await graphql<{ createNode: { id: string } }>(CREATE_NODE, {
|
|
122
|
+
type: node.type,
|
|
123
|
+
title: node.title,
|
|
124
|
+
description: node.description ?? null,
|
|
125
|
+
status: node.status ?? null,
|
|
126
|
+
metadata,
|
|
127
|
+
})
|
|
128
|
+
return data.createNode.id
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const importCommand = new Command('import')
|
|
132
|
+
.description(
|
|
133
|
+
'Ingest a manifest of projects/features/tasks + edges (idempotent by client-key)',
|
|
134
|
+
)
|
|
135
|
+
.argument('<manifest>', 'Path to a JSON manifest file')
|
|
136
|
+
.action(async (manifestPath: string) => {
|
|
137
|
+
try {
|
|
138
|
+
const manifest = parseManifest(readFileSync(manifestPath, 'utf-8'))
|
|
139
|
+
const existing = await loadExisting()
|
|
140
|
+
|
|
141
|
+
// Pass 1 — upsert every node, stamping its client-key into metadata.
|
|
142
|
+
// Known keys update, new keys create, so a re-import never duplicates.
|
|
143
|
+
const idByKey = new Map<string, string>()
|
|
144
|
+
for (const node of manifest.nodes) {
|
|
145
|
+
idByKey.set(node.key, await upsertNode(node, existing.get(node.key)))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Dedup against edges that already exist server-side. Only nodes that
|
|
149
|
+
// pre-existed this import can already have edges, so query just those.
|
|
150
|
+
const preExistingIds = manifest.nodes
|
|
151
|
+
.filter((n) => existing.has(n.key))
|
|
152
|
+
.map((n) => idByKey.get(n.key))
|
|
153
|
+
.filter((id): id is string => id != null)
|
|
154
|
+
const present = await loadExistingEdges(preExistingIds)
|
|
155
|
+
|
|
156
|
+
// Pass 2 — materialize edges, deduped by (source,target,relation). Skip
|
|
157
|
+
// any edge that already exists so the non-idempotent createEdge is never
|
|
158
|
+
// asked to re-link.
|
|
159
|
+
let edgeCount = 0
|
|
160
|
+
for (const edge of desiredEdges(manifest)) {
|
|
161
|
+
const sourceId = idByKey.get(edge.sourceKey)
|
|
162
|
+
const targetId = idByKey.get(edge.targetKey)
|
|
163
|
+
if (!sourceId || !targetId) continue
|
|
164
|
+
if (present.has(edgeKey(sourceId, targetId, edge.relation))) continue
|
|
165
|
+
await graphql(CREATE_EDGE, {
|
|
166
|
+
sourceId,
|
|
167
|
+
targetId,
|
|
168
|
+
relation: edge.relation,
|
|
169
|
+
})
|
|
170
|
+
edgeCount++
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
output({
|
|
174
|
+
imported: idByKey.size,
|
|
175
|
+
edges: edgeCount,
|
|
176
|
+
map: Object.fromEntries(idByKey),
|
|
177
|
+
})
|
|
178
|
+
} catch (error) {
|
|
179
|
+
outputError(error)
|
|
180
|
+
}
|
|
181
|
+
})
|
|
@@ -97,16 +97,37 @@ describe('setup command', () => {
|
|
|
97
97
|
)
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
-
test('setup remote
|
|
100
|
+
test('setup remote registers without --tier (tier is optional)', async () => {
|
|
101
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
102
|
+
register: {
|
|
103
|
+
user: {
|
|
104
|
+
id: 'user_1',
|
|
105
|
+
email: 'test@example.com',
|
|
106
|
+
tier: null,
|
|
107
|
+
createdAt: '2026-06-13T00:00:00Z',
|
|
108
|
+
graceEndsAt: null,
|
|
109
|
+
},
|
|
110
|
+
apiKey: 'flowy_key',
|
|
111
|
+
checkoutUrl: null,
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
vi.doMock('../util/client.ts', () => ({
|
|
115
|
+
graphql: mockGraphql,
|
|
116
|
+
}))
|
|
117
|
+
mockSpawnSync.mockReturnValue({ status: 0, stdout: Buffer.from('') })
|
|
118
|
+
|
|
101
119
|
const { setupCommand } = await import('./setup.ts')
|
|
102
120
|
await setupCommand.parseAsync(['remote', '--email', 'test@example.com'], {
|
|
103
121
|
from: 'user',
|
|
104
122
|
})
|
|
105
123
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
124
|
+
// No tier required: registration proceeds, no usage error raised.
|
|
125
|
+
expect(mockOutputError).not.toHaveBeenCalled()
|
|
126
|
+
expect(mockGraphql).toHaveBeenCalledOnce()
|
|
127
|
+
const [, variables] = mockGraphql.mock.calls[0]!
|
|
128
|
+
expect(variables).toEqual({ email: 'test@example.com', tier: undefined })
|
|
129
|
+
expect(mockSaveConfig).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({ mode: 'remote', apiKey: 'flowy_key' }),
|
|
110
131
|
)
|
|
111
132
|
})
|
|
112
133
|
|
|
@@ -221,4 +242,68 @@ describe('setup command', () => {
|
|
|
221
242
|
}),
|
|
222
243
|
)
|
|
223
244
|
})
|
|
245
|
+
|
|
246
|
+
test('setup local warns when the skill install fails', async () => {
|
|
247
|
+
// bun add succeeds, npx skills add fails (non-zero status).
|
|
248
|
+
mockSpawnSync.mockImplementation((cmd: string) =>
|
|
249
|
+
cmd === 'npx' ? { status: 1 } : { status: 0 },
|
|
250
|
+
)
|
|
251
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
252
|
+
|
|
253
|
+
const { setupCommand } = await import('./setup.ts')
|
|
254
|
+
await setupCommand.parseAsync(['local'], { from: 'user' })
|
|
255
|
+
|
|
256
|
+
const warned = errSpy.mock.calls.map((c) => String(c[0])).join('\n')
|
|
257
|
+
expect(warned).toMatch(/skill/i)
|
|
258
|
+
expect(warned).toContain('npx skills add sqaoss/flowy')
|
|
259
|
+
// Setup itself still completes successfully (config saved, result emitted).
|
|
260
|
+
expect(mockOutput).toHaveBeenCalled()
|
|
261
|
+
expect(mockOutputError).not.toHaveBeenCalled()
|
|
262
|
+
errSpy.mockRestore()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('setup local does not warn when the skill install succeeds', async () => {
|
|
266
|
+
mockSpawnSync.mockReturnValue({ status: 0 })
|
|
267
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
268
|
+
|
|
269
|
+
const { setupCommand } = await import('./setup.ts')
|
|
270
|
+
await setupCommand.parseAsync(['local'], { from: 'user' })
|
|
271
|
+
|
|
272
|
+
const warned = errSpy.mock.calls.map((c) => String(c[0])).join('\n')
|
|
273
|
+
expect(warned).not.toMatch(/skill/i)
|
|
274
|
+
errSpy.mockRestore()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('setup remote warns when the skill install fails', async () => {
|
|
278
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
279
|
+
register: {
|
|
280
|
+
user: {
|
|
281
|
+
id: 'user_1',
|
|
282
|
+
email: 'a@b.com',
|
|
283
|
+
tier: null,
|
|
284
|
+
createdAt: '2026-06-13T00:00:00Z',
|
|
285
|
+
graceEndsAt: null,
|
|
286
|
+
},
|
|
287
|
+
apiKey: 'flowy_key',
|
|
288
|
+
checkoutUrl: null,
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
vi.doMock('../util/client.ts', () => ({ graphql: mockGraphql }))
|
|
292
|
+
mockSpawnSync.mockImplementation((cmd: string) =>
|
|
293
|
+
cmd === 'npx' ? { status: 1 } : { status: 0 },
|
|
294
|
+
)
|
|
295
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
296
|
+
|
|
297
|
+
const { setupCommand } = await import('./setup.ts')
|
|
298
|
+
await setupCommand.parseAsync(['remote', '--email', 'a@b.com'], {
|
|
299
|
+
from: 'user',
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
const warned = errSpy.mock.calls.map((c) => String(c[0])).join('\n')
|
|
303
|
+
expect(warned).toMatch(/skill/i)
|
|
304
|
+
expect(warned).toContain('npx skills add sqaoss/flowy')
|
|
305
|
+
expect(mockOutput).toHaveBeenCalled()
|
|
306
|
+
expect(mockOutputError).not.toHaveBeenCalled()
|
|
307
|
+
errSpy.mockRestore()
|
|
308
|
+
})
|
|
224
309
|
})
|
package/src/commands/setup.ts
CHANGED
|
@@ -8,6 +8,32 @@ export const setupCommand = new Command('setup').description(
|
|
|
8
8
|
'Configure the Flowy CLI \u2014 use "flowy setup local" or "flowy setup remote"',
|
|
9
9
|
)
|
|
10
10
|
|
|
11
|
+
const SKILL_PACKAGE = 'sqaoss/flowy'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Install the Flowy agent skill, surfacing failure instead of swallowing it.
|
|
15
|
+
*
|
|
16
|
+
* `npx skills add` can fail (offline, npx unavailable, registry hiccup). If it
|
|
17
|
+
* does, setup should still succeed \u2014 but the user must be told the skill was
|
|
18
|
+
* NOT installed, with the exact command to retry, rather than silently
|
|
19
|
+
* assuming their agent now knows the commands (F14).
|
|
20
|
+
*/
|
|
21
|
+
function installSkill(): void {
|
|
22
|
+
const result = spawnSync('npx', ['skills', 'add', SKILL_PACKAGE, '--yes'], {
|
|
23
|
+
stdio: 'inherit',
|
|
24
|
+
})
|
|
25
|
+
if (result.error != null || result.status !== 0) {
|
|
26
|
+
const reason = result.error
|
|
27
|
+
? result.error.message
|
|
28
|
+
: `exit code ${result.status}`
|
|
29
|
+
console.error(
|
|
30
|
+
`Warning: failed to install the Flowy agent skill (${reason}). ` +
|
|
31
|
+
`Your agent will not know Flowy's commands until you install it manually:\n` +
|
|
32
|
+
` npx skills add ${SKILL_PACKAGE}`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
11
37
|
setupCommand
|
|
12
38
|
.command('local')
|
|
13
39
|
.description('Set up Flowy with a native local server (no Docker)')
|
|
@@ -26,9 +52,7 @@ setupCommand
|
|
|
26
52
|
config.mode = 'local'
|
|
27
53
|
config.apiUrl = apiUrl
|
|
28
54
|
saveConfig(config)
|
|
29
|
-
|
|
30
|
-
stdio: 'inherit',
|
|
31
|
-
})
|
|
55
|
+
installSkill()
|
|
32
56
|
|
|
33
57
|
output({
|
|
34
58
|
mode: 'local',
|
|
@@ -46,20 +70,16 @@ setupCommand
|
|
|
46
70
|
.description('Connect to the hosted Flowy service')
|
|
47
71
|
.option('--email <email>', 'Email address for registration')
|
|
48
72
|
.addOption(
|
|
49
|
-
new Option(
|
|
50
|
-
'
|
|
51
|
-
'
|
|
52
|
-
|
|
53
|
-
]),
|
|
73
|
+
new Option(
|
|
74
|
+
'--tier <tier>',
|
|
75
|
+
'Subscription tier (optional — pick one later at checkout)',
|
|
76
|
+
).choices(['explorer', 'pro', 'team']),
|
|
54
77
|
)
|
|
55
78
|
.action(async (opts) => {
|
|
56
79
|
try {
|
|
57
80
|
if (!opts.email) {
|
|
58
81
|
throw new Error('--email is required for registration')
|
|
59
82
|
}
|
|
60
|
-
if (!opts.tier) {
|
|
61
|
-
throw new Error('--tier is required for registration')
|
|
62
|
-
}
|
|
63
83
|
|
|
64
84
|
const { graphql } = await import('../util/client.ts')
|
|
65
85
|
|
|
@@ -81,7 +101,7 @@ setupCommand
|
|
|
81
101
|
checkoutUrl: string
|
|
82
102
|
}
|
|
83
103
|
}>(
|
|
84
|
-
`mutation Register($email: String!, $tier: String
|
|
104
|
+
`mutation Register($email: String!, $tier: String) {
|
|
85
105
|
register(email: $email, tier: $tier) {
|
|
86
106
|
user { id email tier createdAt graceEndsAt }
|
|
87
107
|
apiKey
|
|
@@ -94,9 +114,7 @@ setupCommand
|
|
|
94
114
|
config.apiKey = data.register.apiKey
|
|
95
115
|
saveConfig(config)
|
|
96
116
|
|
|
97
|
-
|
|
98
|
-
stdio: 'inherit',
|
|
99
|
-
})
|
|
117
|
+
installSkill()
|
|
100
118
|
|
|
101
119
|
output(data.register)
|
|
102
120
|
} catch (error) {
|
package/src/index.test.ts
CHANGED
|
@@ -42,6 +42,12 @@ vi.mock('./commands/key.ts', () => ({
|
|
|
42
42
|
vi.mock('./commands/serve.ts', () => ({
|
|
43
43
|
serveCommand: { name: () => 'serve' },
|
|
44
44
|
}))
|
|
45
|
+
vi.mock('./commands/import.ts', () => ({
|
|
46
|
+
importCommand: { name: () => 'import' },
|
|
47
|
+
}))
|
|
48
|
+
vi.mock('./commands/export.ts', () => ({
|
|
49
|
+
exportCommand: { name: () => 'export' },
|
|
50
|
+
}))
|
|
45
51
|
|
|
46
52
|
describe('index.ts command registration', () => {
|
|
47
53
|
test('registers billing and key commands', async () => {
|
|
@@ -73,4 +79,21 @@ describe('index.ts command registration', () => {
|
|
|
73
79
|
)
|
|
74
80
|
expect(indexSource).toContain('program.addCommand(serveCommand)')
|
|
75
81
|
})
|
|
82
|
+
|
|
83
|
+
test('registers the import and export commands', async () => {
|
|
84
|
+
const { readFileSync } = await import('node:fs')
|
|
85
|
+
const indexSource = readFileSync(
|
|
86
|
+
new URL('./index.ts', import.meta.url).pathname,
|
|
87
|
+
'utf-8',
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
expect(indexSource).toContain(
|
|
91
|
+
"import { importCommand } from './commands/import.ts'",
|
|
92
|
+
)
|
|
93
|
+
expect(indexSource).toContain(
|
|
94
|
+
"import { exportCommand } from './commands/export.ts'",
|
|
95
|
+
)
|
|
96
|
+
expect(indexSource).toContain('program.addCommand(importCommand)')
|
|
97
|
+
expect(indexSource).toContain('program.addCommand(exportCommand)')
|
|
98
|
+
})
|
|
76
99
|
})
|
package/src/index.ts
CHANGED
|
@@ -14,7 +14,9 @@ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
|
14
14
|
import { approveCommand } from './commands/approve.ts'
|
|
15
15
|
import { billingCommand } from './commands/billing.ts'
|
|
16
16
|
import { clientCommand } from './commands/client.ts'
|
|
17
|
+
import { exportCommand } from './commands/export.ts'
|
|
17
18
|
import { featureCommand } from './commands/feature.ts'
|
|
19
|
+
import { importCommand } from './commands/import.ts'
|
|
18
20
|
import { initCommand } from './commands/init.ts'
|
|
19
21
|
import { keyCommand } from './commands/key.ts'
|
|
20
22
|
import { projectCommand } from './commands/project.ts'
|
|
@@ -45,5 +47,7 @@ program.addCommand(keyCommand)
|
|
|
45
47
|
program.addCommand(searchCommand)
|
|
46
48
|
program.addCommand(treeCommand)
|
|
47
49
|
program.addCommand(whoamiCommand)
|
|
50
|
+
program.addCommand(importCommand)
|
|
51
|
+
program.addCommand(exportCommand)
|
|
48
52
|
|
|
49
53
|
program.parse()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildNodeMetadata,
|
|
4
|
+
FLOWY_KEY_FIELD,
|
|
5
|
+
type Manifest,
|
|
6
|
+
parseManifest,
|
|
7
|
+
readClientKey,
|
|
8
|
+
serializeManifest,
|
|
9
|
+
stripClientKey,
|
|
10
|
+
} from './manifest.ts'
|
|
11
|
+
|
|
12
|
+
const VALID: Manifest = {
|
|
13
|
+
version: 1,
|
|
14
|
+
nodes: [
|
|
15
|
+
{ key: 'proj', type: 'project', title: 'Demo' },
|
|
16
|
+
{ key: 'feat-1', type: 'feature', title: 'Auth', parent: 'proj' },
|
|
17
|
+
{
|
|
18
|
+
key: 'task-1',
|
|
19
|
+
type: 'task',
|
|
20
|
+
title: 'Login',
|
|
21
|
+
parent: 'feat-1',
|
|
22
|
+
status: 'draft',
|
|
23
|
+
metadata: { priority: 'high' },
|
|
24
|
+
},
|
|
25
|
+
{ key: 'task-2', type: 'task', title: 'Logout', parent: 'feat-1' },
|
|
26
|
+
],
|
|
27
|
+
edges: [{ source: 'task-2', target: 'task-1', relation: 'blocks' }],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('parseManifest', () => {
|
|
31
|
+
test('parses a well-formed JSON manifest', () => {
|
|
32
|
+
const m = parseManifest(JSON.stringify(VALID))
|
|
33
|
+
expect(m.version).toBe(1)
|
|
34
|
+
expect(m.nodes).toHaveLength(4)
|
|
35
|
+
expect(m.edges).toHaveLength(1)
|
|
36
|
+
expect(m.nodes[0]).toMatchObject({ key: 'proj', type: 'project' })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('defaults edges to an empty array when omitted', () => {
|
|
40
|
+
const m = parseManifest(
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
version: 1,
|
|
43
|
+
nodes: [{ key: 'proj', type: 'project', title: 'Demo' }],
|
|
44
|
+
}),
|
|
45
|
+
)
|
|
46
|
+
expect(m.edges).toEqual([])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('throws on invalid JSON', () => {
|
|
50
|
+
expect(() => parseManifest('{not json')).toThrow(/invalid json/i)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('throws when nodes is missing', () => {
|
|
54
|
+
expect(() => parseManifest(JSON.stringify({ version: 1 }))).toThrow(
|
|
55
|
+
/nodes/i,
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('throws when a node lacks a key', () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
parseManifest(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
version: 1,
|
|
64
|
+
nodes: [{ type: 'project', title: 'Demo' }],
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
).toThrow(/key/i)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('throws when a node lacks a type', () => {
|
|
71
|
+
expect(() =>
|
|
72
|
+
parseManifest(
|
|
73
|
+
JSON.stringify({
|
|
74
|
+
version: 1,
|
|
75
|
+
nodes: [{ key: 'x', title: 'Demo' }],
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
).toThrow(/type/i)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('throws on a duplicate client-key', () => {
|
|
82
|
+
expect(() =>
|
|
83
|
+
parseManifest(
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
version: 1,
|
|
86
|
+
nodes: [
|
|
87
|
+
{ key: 'dup', type: 'project', title: 'A' },
|
|
88
|
+
{ key: 'dup', type: 'feature', title: 'B' },
|
|
89
|
+
],
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
).toThrow(/duplicate/i)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('throws when an edge references an unknown key', () => {
|
|
96
|
+
expect(() =>
|
|
97
|
+
parseManifest(
|
|
98
|
+
JSON.stringify({
|
|
99
|
+
version: 1,
|
|
100
|
+
nodes: [{ key: 'a', type: 'project', title: 'A' }],
|
|
101
|
+
edges: [{ source: 'a', target: 'ghost', relation: 'blocks' }],
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
).toThrow(/ghost/i)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('serializeManifest', () => {
|
|
109
|
+
test('round-trips through parse', () => {
|
|
110
|
+
const text = serializeManifest(VALID)
|
|
111
|
+
expect(parseManifest(text)).toEqual(VALID)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('emits pretty-printed JSON', () => {
|
|
115
|
+
const text = serializeManifest(VALID)
|
|
116
|
+
expect(text).toContain('\n')
|
|
117
|
+
expect(text.endsWith('\n')).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('node metadata client-key', () => {
|
|
122
|
+
test('the reserved field is a single scalar key, not an edge namespace', () => {
|
|
123
|
+
expect(FLOWY_KEY_FIELD).toBe('__flowyKey')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('buildNodeMetadata stamps the client-key alongside user metadata', () => {
|
|
127
|
+
const meta = JSON.parse(buildNodeMetadata('task-1', { priority: 'high' }))
|
|
128
|
+
expect(meta).toEqual({ priority: 'high', __flowyKey: 'task-1' })
|
|
129
|
+
// No edge data is ever stored in metadata.
|
|
130
|
+
expect(meta.__flowy).toBeUndefined()
|
|
131
|
+
expect(meta.edges).toBeUndefined()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('buildNodeMetadata works with no user metadata', () => {
|
|
135
|
+
expect(JSON.parse(buildNodeMetadata('proj'))).toEqual({
|
|
136
|
+
__flowyKey: 'proj',
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('readClientKey extracts the key', () => {
|
|
141
|
+
expect(readClientKey(buildNodeMetadata('feat-1', { a: 1 }))).toBe('feat-1')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('readClientKey returns null for absent/invalid metadata', () => {
|
|
145
|
+
expect(readClientKey(null)).toBeNull()
|
|
146
|
+
expect(readClientKey('{not json')).toBeNull()
|
|
147
|
+
expect(readClientKey(JSON.stringify({ a: 1 }))).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('stripClientKey returns only user metadata', () => {
|
|
151
|
+
expect(
|
|
152
|
+
stripClientKey(buildNodeMetadata('x', { priority: 'high' })),
|
|
153
|
+
).toEqual({ priority: 'high' })
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('stripClientKey returns undefined when only the key was stored', () => {
|
|
157
|
+
expect(stripClientKey(buildNodeMetadata('x'))).toBeUndefined()
|
|
158
|
+
})
|
|
159
|
+
})
|