@sqaoss/flowy 1.6.1 → 1.7.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/package.json +1 -1
- package/src/commands/key.test.ts +45 -7
- package/src/commands/key.ts +14 -4
- package/src/commands/setup.test.ts +47 -6
- package/src/commands/setup.ts +12 -2
- package/src/commands/whoami.test.ts +31 -2
- package/src/commands/whoami.ts +8 -2
- package/src/util/config.test.ts +50 -2
- package/src/util/config.ts +37 -4
package/package.json
CHANGED
package/src/commands/key.test.ts
CHANGED
|
@@ -17,10 +17,17 @@ beforeEach(() => {
|
|
|
17
17
|
mockOutput = vi.fn()
|
|
18
18
|
mockOutputError = vi.fn()
|
|
19
19
|
|
|
20
|
-
vi.doMock('../util/config.ts', () =>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
vi.doMock('../util/config.ts', async () => {
|
|
21
|
+
const actual =
|
|
22
|
+
await vi.importActual<typeof import('../util/config.ts')>(
|
|
23
|
+
'../util/config.ts',
|
|
24
|
+
)
|
|
25
|
+
return {
|
|
26
|
+
loadConfig: mockLoadConfig,
|
|
27
|
+
saveConfig: mockSaveConfig,
|
|
28
|
+
fingerprintKey: actual.fingerprintKey,
|
|
29
|
+
}
|
|
30
|
+
})
|
|
24
31
|
|
|
25
32
|
vi.doMock('../util/format.ts', () => ({
|
|
26
33
|
output: mockOutput,
|
|
@@ -46,7 +53,7 @@ describe('key command', () => {
|
|
|
46
53
|
expect(keyCommand.commands).toHaveLength(1)
|
|
47
54
|
})
|
|
48
55
|
|
|
49
|
-
test('rotate calls rotateApiKey mutation, saves new key to config, and outputs
|
|
56
|
+
test('rotate calls rotateApiKey mutation, saves new key to config, and outputs a fingerprint (not the secret)', async () => {
|
|
50
57
|
const mockGraphql = vi.fn().mockResolvedValue({
|
|
51
58
|
rotateApiKey: {
|
|
52
59
|
user: {
|
|
@@ -74,12 +81,43 @@ describe('key command', () => {
|
|
|
74
81
|
apiKey: 'flowy_new_key_456',
|
|
75
82
|
}),
|
|
76
83
|
)
|
|
77
|
-
|
|
84
|
+
|
|
85
|
+
// Default output must NOT leak the full secret.
|
|
86
|
+
const outputArg = mockOutput.mock.calls[0]![0]
|
|
87
|
+
expect(JSON.stringify(outputArg)).not.toContain('flowy_new_key_456')
|
|
88
|
+
expect(outputArg).toEqual(
|
|
78
89
|
expect.objectContaining({
|
|
79
90
|
user: expect.objectContaining({ email: 'test@example.com' }),
|
|
80
|
-
|
|
91
|
+
keyFingerprint: expect.stringMatching(/sha256:[0-9a-f]{12}/),
|
|
81
92
|
}),
|
|
82
93
|
)
|
|
94
|
+
expect(outputArg).not.toHaveProperty('apiKey')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('rotate --show-key reveals the full secret', async () => {
|
|
98
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
99
|
+
rotateApiKey: {
|
|
100
|
+
user: {
|
|
101
|
+
id: 'user_1',
|
|
102
|
+
email: 'test@example.com',
|
|
103
|
+
tier: 'free',
|
|
104
|
+
createdAt: '2025-01-01T00:00:00Z',
|
|
105
|
+
graceEndsAt: null,
|
|
106
|
+
},
|
|
107
|
+
apiKey: 'flowy_new_key_456',
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
vi.doMock('../util/client.ts', () => ({
|
|
111
|
+
graphql: mockGraphql,
|
|
112
|
+
}))
|
|
113
|
+
|
|
114
|
+
const { keyCommand } = await import('./key.ts')
|
|
115
|
+
await keyCommand.parseAsync(['rotate', '--show-key'], { from: 'user' })
|
|
116
|
+
|
|
117
|
+
const outputArg = mockOutput.mock.calls[0]![0]
|
|
118
|
+
expect(outputArg).toEqual(
|
|
119
|
+
expect.objectContaining({ apiKey: 'flowy_new_key_456' }),
|
|
120
|
+
)
|
|
83
121
|
})
|
|
84
122
|
|
|
85
123
|
test('rotate saves the exact new apiKey to config', async () => {
|
package/src/commands/key.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
|
-
import { loadConfig, saveConfig } from '../util/config.ts'
|
|
3
|
+
import { fingerprintKey, loadConfig, saveConfig } from '../util/config.ts'
|
|
4
4
|
import { output, outputError } from '../util/format.ts'
|
|
5
5
|
|
|
6
6
|
export const keyCommand = new Command('key').description('API key management')
|
|
@@ -8,7 +8,11 @@ export const keyCommand = new Command('key').description('API key management')
|
|
|
8
8
|
keyCommand
|
|
9
9
|
.command('rotate')
|
|
10
10
|
.description('Rotate API key')
|
|
11
|
-
.
|
|
11
|
+
.option(
|
|
12
|
+
'--show-key',
|
|
13
|
+
'Print the full API key instead of a non-reversible fingerprint',
|
|
14
|
+
)
|
|
15
|
+
.action(async (opts) => {
|
|
12
16
|
try {
|
|
13
17
|
const data = await graphql<{
|
|
14
18
|
rotateApiKey: {
|
|
@@ -30,11 +34,17 @@ keyCommand
|
|
|
30
34
|
}`,
|
|
31
35
|
)
|
|
32
36
|
|
|
37
|
+
const { user, apiKey } = data.rotateApiKey
|
|
33
38
|
const config = loadConfig()
|
|
34
|
-
config.apiKey =
|
|
39
|
+
config.apiKey = apiKey
|
|
35
40
|
saveConfig(config)
|
|
36
41
|
|
|
37
|
-
output(
|
|
42
|
+
// Default output never leaks the secret; --show-key opts in (F35).
|
|
43
|
+
output(
|
|
44
|
+
opts.showKey
|
|
45
|
+
? { user, apiKey }
|
|
46
|
+
: { user, keyFingerprint: fingerprintKey(apiKey) },
|
|
47
|
+
)
|
|
38
48
|
} catch (error) {
|
|
39
49
|
outputError(error)
|
|
40
50
|
}
|
|
@@ -19,10 +19,17 @@ beforeEach(() => {
|
|
|
19
19
|
mockOutputError = vi.fn()
|
|
20
20
|
mockSpawnSync = vi.fn()
|
|
21
21
|
|
|
22
|
-
vi.doMock('../util/config.ts', () =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
vi.doMock('../util/config.ts', async () => {
|
|
23
|
+
const actual =
|
|
24
|
+
await vi.importActual<typeof import('../util/config.ts')>(
|
|
25
|
+
'../util/config.ts',
|
|
26
|
+
)
|
|
27
|
+
return {
|
|
28
|
+
loadConfig: mockLoadConfig,
|
|
29
|
+
saveConfig: mockSaveConfig,
|
|
30
|
+
fingerprintKey: actual.fingerprintKey,
|
|
31
|
+
}
|
|
32
|
+
})
|
|
26
33
|
|
|
27
34
|
vi.doMock('../util/format.ts', () => ({
|
|
28
35
|
output: mockOutput,
|
|
@@ -230,17 +237,51 @@ describe('setup command', () => {
|
|
|
230
237
|
apiKey: 'flowy_test_key_123',
|
|
231
238
|
}),
|
|
232
239
|
)
|
|
233
|
-
|
|
240
|
+
// Default output surfaces a fingerprint, never the raw secret (F35).
|
|
241
|
+
const outputArg = mockOutput.mock.calls[0]![0]
|
|
242
|
+
expect(JSON.stringify(outputArg)).not.toContain('flowy_test_key_123')
|
|
243
|
+
expect(outputArg).toEqual(
|
|
234
244
|
expect.objectContaining({
|
|
235
245
|
user: expect.objectContaining({
|
|
236
246
|
email: 'test@example.com',
|
|
237
247
|
tier: 'explorer',
|
|
238
248
|
graceEndsAt: '2026-04-13T00:00:00Z',
|
|
239
249
|
}),
|
|
240
|
-
|
|
250
|
+
keyFingerprint: expect.stringMatching(/sha256:[0-9a-f]{12}/),
|
|
241
251
|
checkoutUrl: 'https://checkout.stripe.com/session_123',
|
|
242
252
|
}),
|
|
243
253
|
)
|
|
254
|
+
expect(outputArg).not.toHaveProperty('apiKey')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('setup remote --show-key reveals the full API key', async () => {
|
|
258
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
259
|
+
register: {
|
|
260
|
+
user: {
|
|
261
|
+
id: 'user_1',
|
|
262
|
+
email: 'test@example.com',
|
|
263
|
+
tier: 'explorer',
|
|
264
|
+
createdAt: '2026-03-30T00:00:00Z',
|
|
265
|
+
graceEndsAt: null,
|
|
266
|
+
},
|
|
267
|
+
apiKey: 'flowy_test_key_123',
|
|
268
|
+
checkoutUrl: null,
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
vi.doMock('../util/client.ts', () => ({
|
|
272
|
+
graphql: mockGraphql,
|
|
273
|
+
}))
|
|
274
|
+
mockSpawnSync.mockReturnValue({ status: 0, stdout: Buffer.from('') })
|
|
275
|
+
|
|
276
|
+
const { setupCommand } = await import('./setup.ts')
|
|
277
|
+
await setupCommand.parseAsync(
|
|
278
|
+
['remote', '--email', 'test@example.com', '--show-key'],
|
|
279
|
+
{ from: 'user' },
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
expect(mockOutput).toHaveBeenCalledWith(
|
|
283
|
+
expect.objectContaining({ apiKey: 'flowy_test_key_123' }),
|
|
284
|
+
)
|
|
244
285
|
})
|
|
245
286
|
|
|
246
287
|
test('setup local warns when the skill install fails', async () => {
|
package/src/commands/setup.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
2
|
import { Command, Option } from 'commander'
|
|
3
|
-
import { loadConfig, saveConfig } from '../util/config.ts'
|
|
3
|
+
import { fingerprintKey, loadConfig, saveConfig } from '../util/config.ts'
|
|
4
4
|
import { output, outputError } from '../util/format.ts'
|
|
5
5
|
import { pinnedInstallSpec } from './serve.ts'
|
|
6
6
|
|
|
@@ -69,6 +69,10 @@ setupCommand
|
|
|
69
69
|
.command('remote')
|
|
70
70
|
.description('Connect to the hosted Flowy service')
|
|
71
71
|
.option('--email <email>', 'Email address for registration')
|
|
72
|
+
.option(
|
|
73
|
+
'--show-key',
|
|
74
|
+
'Print the full API key instead of a non-reversible fingerprint',
|
|
75
|
+
)
|
|
72
76
|
.addOption(
|
|
73
77
|
new Option(
|
|
74
78
|
'--tier <tier>',
|
|
@@ -116,7 +120,13 @@ setupCommand
|
|
|
116
120
|
|
|
117
121
|
installSkill()
|
|
118
122
|
|
|
119
|
-
|
|
123
|
+
const { user, apiKey, checkoutUrl } = data.register
|
|
124
|
+
// Default output never leaks the secret; --show-key opts in (F35).
|
|
125
|
+
output(
|
|
126
|
+
opts.showKey
|
|
127
|
+
? { user, apiKey, checkoutUrl }
|
|
128
|
+
: { user, keyFingerprint: fingerprintKey(apiKey), checkoutUrl },
|
|
129
|
+
)
|
|
120
130
|
} catch (error) {
|
|
121
131
|
outputError(error)
|
|
122
132
|
}
|
|
@@ -3,10 +3,18 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
|
3
3
|
let mockGraphql: ReturnType<typeof vi.fn>
|
|
4
4
|
let mockOutput: ReturnType<typeof vi.fn>
|
|
5
5
|
let mockOutputError: ReturnType<typeof vi.fn>
|
|
6
|
+
let mockLoadConfig: ReturnType<typeof vi.fn>
|
|
6
7
|
|
|
7
8
|
beforeEach(() => {
|
|
8
9
|
mockOutput = vi.fn()
|
|
9
10
|
mockOutputError = vi.fn()
|
|
11
|
+
mockLoadConfig = vi.fn(() => ({
|
|
12
|
+
mode: 'saas',
|
|
13
|
+
apiUrl: 'https://flowy-ai.fly.dev/graphql',
|
|
14
|
+
apiKey: 'flowy_secret_abcdef0123456789',
|
|
15
|
+
client: { name: '' },
|
|
16
|
+
projects: {},
|
|
17
|
+
}))
|
|
10
18
|
mockGraphql = vi.fn().mockResolvedValue({
|
|
11
19
|
whoami: {
|
|
12
20
|
id: 'user_1',
|
|
@@ -25,6 +33,17 @@ beforeEach(() => {
|
|
|
25
33
|
vi.doMock('../util/client.ts', () => ({
|
|
26
34
|
graphql: mockGraphql,
|
|
27
35
|
}))
|
|
36
|
+
|
|
37
|
+
vi.doMock('../util/config.ts', async () => {
|
|
38
|
+
const actual =
|
|
39
|
+
await vi.importActual<typeof import('../util/config.ts')>(
|
|
40
|
+
'../util/config.ts',
|
|
41
|
+
)
|
|
42
|
+
return {
|
|
43
|
+
loadConfig: mockLoadConfig,
|
|
44
|
+
fingerprintKey: actual.fingerprintKey,
|
|
45
|
+
}
|
|
46
|
+
})
|
|
28
47
|
})
|
|
29
48
|
|
|
30
49
|
afterEach(() => {
|
|
@@ -33,7 +52,7 @@ afterEach(() => {
|
|
|
33
52
|
})
|
|
34
53
|
|
|
35
54
|
describe('whoami command', () => {
|
|
36
|
-
test('whoami outputs user data
|
|
55
|
+
test('whoami outputs user data plus a non-reversible key fingerprint', async () => {
|
|
37
56
|
const userData = {
|
|
38
57
|
id: '1',
|
|
39
58
|
email: 'a@b.com',
|
|
@@ -46,7 +65,17 @@ describe('whoami command', () => {
|
|
|
46
65
|
const { whoamiCommand } = await import('./whoami.ts')
|
|
47
66
|
await whoamiCommand.parseAsync([], { from: 'user' })
|
|
48
67
|
|
|
49
|
-
|
|
68
|
+
const outputArg = mockOutput.mock.calls[0]![0]
|
|
69
|
+
expect(outputArg).toEqual(
|
|
70
|
+
expect.objectContaining({
|
|
71
|
+
...userData,
|
|
72
|
+
keyFingerprint: expect.stringMatching(/sha256:[0-9a-f]{12}/),
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
// Fingerprint must not leak the configured secret.
|
|
76
|
+
expect(JSON.stringify(outputArg)).not.toContain(
|
|
77
|
+
'flowy_secret_abcdef0123456789',
|
|
78
|
+
)
|
|
50
79
|
})
|
|
51
80
|
|
|
52
81
|
test('whoami outputs error when query fails', async () => {
|
package/src/commands/whoami.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
|
+
import { fingerprintKey, loadConfig } from '../util/config.ts'
|
|
3
4
|
import { output, outputError } from '../util/format.ts'
|
|
4
5
|
|
|
5
6
|
export const whoamiCommand = new Command('whoami')
|
|
6
7
|
.description('Show current user info')
|
|
7
8
|
.action(async () => {
|
|
8
9
|
try {
|
|
9
|
-
const data = await graphql<{ whoami: unknown }>(
|
|
10
|
+
const data = await graphql<{ whoami: Record<string, unknown> }>(
|
|
10
11
|
`query Whoami {
|
|
11
12
|
whoami {
|
|
12
13
|
id email tier createdAt graceEndsAt
|
|
13
14
|
}
|
|
14
15
|
}`,
|
|
15
16
|
)
|
|
16
|
-
|
|
17
|
+
// Surface a non-reversible fingerprint of the configured key so a human
|
|
18
|
+
// can confirm *which* credential is active without exposing it (F35).
|
|
19
|
+
output({
|
|
20
|
+
...data.whoami,
|
|
21
|
+
keyFingerprint: fingerprintKey(loadConfig().apiKey),
|
|
22
|
+
})
|
|
17
23
|
} catch (error) {
|
|
18
24
|
outputError(error)
|
|
19
25
|
}
|
package/src/util/config.test.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
statSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { homedir, platform } from 'node:os'
|
|
3
10
|
import { resolve } from 'node:path'
|
|
4
11
|
import {
|
|
5
12
|
afterAll,
|
|
@@ -12,6 +19,8 @@ import {
|
|
|
12
19
|
vi,
|
|
13
20
|
} from 'vitest'
|
|
14
21
|
|
|
22
|
+
const isWindows = platform() === 'win32'
|
|
23
|
+
|
|
15
24
|
const CONFIG_PATH = resolve(homedir(), '.config', 'flowy', 'config.json')
|
|
16
25
|
|
|
17
26
|
describe('config', () => {
|
|
@@ -70,6 +79,45 @@ describe('config', () => {
|
|
|
70
79
|
expect(reloaded.client.name).toBe('Test Client')
|
|
71
80
|
})
|
|
72
81
|
|
|
82
|
+
test.skipIf(isWindows)(
|
|
83
|
+
'saveConfig writes config with 0600 mode',
|
|
84
|
+
async () => {
|
|
85
|
+
const { saveConfig, loadConfig } = await import('./config.ts')
|
|
86
|
+
saveConfig(loadConfig())
|
|
87
|
+
const mode = statSync(CONFIG_PATH).mode & 0o777
|
|
88
|
+
expect(mode).toBe(0o600)
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
test.skipIf(isWindows)(
|
|
93
|
+
'saveConfig corrects a pre-existing 0644 config to 0600',
|
|
94
|
+
async () => {
|
|
95
|
+
const { saveConfig, loadConfig } = await import('./config.ts')
|
|
96
|
+
// Simulate a config written by an older CLI (world-readable).
|
|
97
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(loadConfig(), null, 2))
|
|
98
|
+
chmodSync(CONFIG_PATH, 0o644)
|
|
99
|
+
expect(statSync(CONFIG_PATH).mode & 0o777).toBe(0o644)
|
|
100
|
+
|
|
101
|
+
saveConfig(loadConfig())
|
|
102
|
+
expect(statSync(CONFIG_PATH).mode & 0o777).toBe(0o600)
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
test('fingerprintKey is deterministic and non-reversible', async () => {
|
|
107
|
+
const { fingerprintKey } = await import('./config.ts')
|
|
108
|
+
const key = 'flowy_secret_abcdef0123456789'
|
|
109
|
+
const fp = fingerprintKey(key)
|
|
110
|
+
expect(fingerprintKey(key)).toBe(fp)
|
|
111
|
+
expect(fp).not.toContain(key)
|
|
112
|
+
expect(fp).not.toContain('abcdef0123456789')
|
|
113
|
+
expect(fp).toMatch(/sha256:[0-9a-f]{12}/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('fingerprintKey returns a placeholder for an empty key', async () => {
|
|
117
|
+
const { fingerprintKey } = await import('./config.ts')
|
|
118
|
+
expect(fingerprintKey('')).toBe('(none)')
|
|
119
|
+
})
|
|
120
|
+
|
|
73
121
|
test('resolveProject returns null when no project configured', async () => {
|
|
74
122
|
const { resolveProject } = await import('./config.ts')
|
|
75
123
|
expect(resolveProject()).toBeNull()
|
package/src/util/config.ts
CHANGED
|
@@ -1,10 +1,34 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { homedir, platform } from 'node:os'
|
|
3
10
|
import { resolve } from 'node:path'
|
|
4
11
|
|
|
5
12
|
const CONFIG_DIR = resolve(homedir(), '.config', 'flowy')
|
|
6
13
|
const CONFIG_PATH = resolve(CONFIG_DIR, 'config.json')
|
|
7
14
|
|
|
15
|
+
// Owner-only modes for the config dir/file, which hold the FLOWY_API_KEY.
|
|
16
|
+
// POSIX-only; chmod is a no-op on Windows so we skip it to avoid surprises.
|
|
17
|
+
const DIR_MODE = 0o700
|
|
18
|
+
const FILE_MODE = 0o600
|
|
19
|
+
const isWindows = platform() === 'win32'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Non-reversible fingerprint of an API key, safe to print to stdout/logs.
|
|
23
|
+
* A short SHA-256 prefix lets a human confirm *which* key is configured
|
|
24
|
+
* without exposing the secret itself (F35).
|
|
25
|
+
*/
|
|
26
|
+
export function fingerprintKey(apiKey: string): string {
|
|
27
|
+
if (!apiKey) return '(none)'
|
|
28
|
+
const digest = createHash('sha256').update(apiKey).digest('hex')
|
|
29
|
+
return `sha256:${digest.slice(0, 12)}`
|
|
30
|
+
}
|
|
31
|
+
|
|
8
32
|
export function loadConfig() {
|
|
9
33
|
if (!existsSync(CONFIG_PATH)) {
|
|
10
34
|
return {
|
|
@@ -19,8 +43,17 @@ export function loadConfig() {
|
|
|
19
43
|
}
|
|
20
44
|
|
|
21
45
|
export function saveConfig(config: ReturnType<typeof loadConfig>): void {
|
|
22
|
-
mkdirSync(CONFIG_DIR, { recursive: true })
|
|
23
|
-
writeFileSync
|
|
46
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: DIR_MODE })
|
|
47
|
+
// `mode` on writeFileSync only applies to *newly created* files and is
|
|
48
|
+
// masked by umask, so an explicit chmod afterward both corrects a
|
|
49
|
+
// pre-existing world-readable (0644) config and survives a tight umask.
|
|
50
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), {
|
|
51
|
+
mode: FILE_MODE,
|
|
52
|
+
})
|
|
53
|
+
if (!isWindows) {
|
|
54
|
+
chmodSync(CONFIG_DIR, DIR_MODE)
|
|
55
|
+
chmodSync(CONFIG_PATH, FILE_MODE)
|
|
56
|
+
}
|
|
24
57
|
}
|
|
25
58
|
|
|
26
59
|
export interface ProjectConfig {
|