nova-audio-agent 0.1.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/LICENSE +201 -0
- package/README.md +24 -0
- package/bin/novaaudio.mjs +11 -0
- package/package.json +42 -0
- package/release-assets.json +16 -0
- package/src/command.mjs +49 -0
- package/src/runtime.mjs +447 -0
- package/src/target.mjs +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 DeepNovaCore
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Nova Audio Agent CLI
|
|
2
|
+
|
|
3
|
+
Install the command globally:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g nova-audio-agent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Then launch Nova or open its settings:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
novaaudio
|
|
13
|
+
novaaudio config
|
|
14
|
+
novaaudio doctor
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The CLI downloads the matching `v0.1.0` desktop release into
|
|
18
|
+
`~/.nova-audio-agent/cli/releases/`, verifies its published SHA-256 digest, and
|
|
19
|
+
keeps application settings in the desktop client's existing encrypted store.
|
|
20
|
+
It never reads or prints secret values.
|
|
21
|
+
|
|
22
|
+
The initial release supports macOS arm64 and Windows x64.
|
|
23
|
+
The desktop application is currently unsigned, so the operating system may
|
|
24
|
+
show a security warning.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { main } from '../src/command.mjs'
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
process.exitCode = await main(process.argv.slice(2))
|
|
7
|
+
} catch (error) {
|
|
8
|
+
const message = error instanceof Error ? error.message : 'unknown failure'
|
|
9
|
+
process.stderr.write(`novaaudio: ${message}\n`)
|
|
10
|
+
process.exitCode = 1
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nova-audio-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Install and launch the Nova Audio Agent desktop client from the command line.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"novaaudio": "./bin/novaaudio.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"release-assets.json",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test test/*.test.mjs"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.13.0"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org/"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/deepnovacore/NovaAudioAgent.git"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/deepnovacore/NovaAudioAgent#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/deepnovacore/NovaAudioAgent/issues"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"nova",
|
|
36
|
+
"audio",
|
|
37
|
+
"voice",
|
|
38
|
+
"agent",
|
|
39
|
+
"codex"
|
|
40
|
+
],
|
|
41
|
+
"license": "Apache-2.0"
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"repository": "deepnovacore/NovaAudioAgent",
|
|
4
|
+
"targets": {
|
|
5
|
+
"darwin-arm64": {
|
|
6
|
+
"artifact": "nova-audio-agent-0.1.0-macos-arm64-app.zip",
|
|
7
|
+
"executable": "Nova Audio Agent Ambient Orb.app/Contents/MacOS/Nova Audio Agent Ambient Orb",
|
|
8
|
+
"archive": "zip"
|
|
9
|
+
},
|
|
10
|
+
"win32-x64": {
|
|
11
|
+
"artifact": "nova-audio-agent-0.1.0-windows-x64-portable.zip",
|
|
12
|
+
"executable": "Nova Audio Agent Ambient Orb.exe",
|
|
13
|
+
"archive": "zip"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/command.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ensureDesktop, inspectDoctor, launchDesktop } from './runtime.mjs'
|
|
2
|
+
import { PRODUCT_VERSION } from './target.mjs'
|
|
3
|
+
|
|
4
|
+
export const HELP_TEXT = `Usage: novaaudio [start|config|doctor|--version|--help]
|
|
5
|
+
|
|
6
|
+
novaaudio Install if needed and launch Nova Audio Agent
|
|
7
|
+
novaaudio start Install if needed and launch Nova Audio Agent
|
|
8
|
+
novaaudio config Open the desktop settings window
|
|
9
|
+
novaaudio doctor Inspect local installation and configuration status`
|
|
10
|
+
|
|
11
|
+
export async function main(argv, {
|
|
12
|
+
stdout = process.stdout,
|
|
13
|
+
ensure = ensureDesktop,
|
|
14
|
+
launch = launchDesktop,
|
|
15
|
+
doctor = inspectDoctor,
|
|
16
|
+
} = {}) {
|
|
17
|
+
const command = argv[0] ?? 'start'
|
|
18
|
+
if (argv.length > 1) {
|
|
19
|
+
stdout.write(`${HELP_TEXT}\n`)
|
|
20
|
+
return 2
|
|
21
|
+
}
|
|
22
|
+
if (command === '--help' || command === '-h' || command === 'help') {
|
|
23
|
+
stdout.write(`${HELP_TEXT}\n`)
|
|
24
|
+
return 0
|
|
25
|
+
}
|
|
26
|
+
if (command === '--version' || command === '-v' || command === 'version') {
|
|
27
|
+
stdout.write(`${PRODUCT_VERSION}\n`)
|
|
28
|
+
return 0
|
|
29
|
+
}
|
|
30
|
+
if (command === 'doctor') {
|
|
31
|
+
const report = await doctor()
|
|
32
|
+
stdout.write(`Nova Audio Agent ${PRODUCT_VERSION}\n`)
|
|
33
|
+
stdout.write(`Platform: ${report.platform} (${report.supported ? 'supported' : 'unsupported'})\n`)
|
|
34
|
+
if (report.supported) {
|
|
35
|
+
stdout.write(`Desktop cache: ${report.desktopReady ? 'ready' : 'missing'}\n`)
|
|
36
|
+
stdout.write(`Settings: ${report.settingsPresent ? 'present' : 'missing'}\n`)
|
|
37
|
+
stdout.write(`Configured keys: ${report.configuredSecretKeys.length === 0 ? 'none' : report.configuredSecretKeys.join(', ')}\n`)
|
|
38
|
+
stdout.write(`Codex: ${report.codexPresent ? 'found' : 'missing'}\n`)
|
|
39
|
+
}
|
|
40
|
+
return report.supported ? 0 : 1
|
|
41
|
+
}
|
|
42
|
+
if (command !== 'start' && command !== 'config') {
|
|
43
|
+
stdout.write(`${HELP_TEXT}\n`)
|
|
44
|
+
return 2
|
|
45
|
+
}
|
|
46
|
+
const installed = await ensure()
|
|
47
|
+
await launch(installed.executable, {openSettings: command === 'config'})
|
|
48
|
+
return 0
|
|
49
|
+
}
|
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
chmod,
|
|
6
|
+
copyFile,
|
|
7
|
+
lstat,
|
|
8
|
+
mkdir,
|
|
9
|
+
mkdtemp,
|
|
10
|
+
open,
|
|
11
|
+
readFile,
|
|
12
|
+
rename,
|
|
13
|
+
rm,
|
|
14
|
+
stat,
|
|
15
|
+
utimes,
|
|
16
|
+
writeFile,
|
|
17
|
+
} from 'node:fs/promises'
|
|
18
|
+
import { constants as fsConstants } from 'node:fs'
|
|
19
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
desktopSettingsPath,
|
|
23
|
+
PRODUCT_VERSION,
|
|
24
|
+
releaseBaseUrl,
|
|
25
|
+
releaseRoot,
|
|
26
|
+
resolveTarget,
|
|
27
|
+
} from './target.mjs'
|
|
28
|
+
|
|
29
|
+
const MAX_ARTIFACT_BYTES = 512 * 1024 * 1024
|
|
30
|
+
const MAX_CHECKSUM_BYTES = 4096
|
|
31
|
+
const MAX_REDIRECTS = 5
|
|
32
|
+
const LOCK_STALE_MS = 15 * 60 * 1000
|
|
33
|
+
const LOCK_WAIT_MS = 2 * 60 * 1000
|
|
34
|
+
const REDIRECT_DELAY_MS = 100
|
|
35
|
+
const CHECKSUM_PATTERN = /^[a-f0-9]{64}$/u
|
|
36
|
+
const INSTALL_RECEIPT_BYTES = 4096
|
|
37
|
+
const LOCK_HEARTBEAT_MS = 30 * 1000
|
|
38
|
+
const LAUNCH_GRACE_MS = 1000
|
|
39
|
+
|
|
40
|
+
function allowedDownloadUrl(value) {
|
|
41
|
+
const url = new URL(value)
|
|
42
|
+
return url.protocol === 'https:'
|
|
43
|
+
&& (url.hostname === 'github.com' || url.hostname.endsWith('.githubusercontent.com'))
|
|
44
|
+
&& url.username === ''
|
|
45
|
+
&& url.password === ''
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function request(url, {fetchImpl = fetch} = {}) {
|
|
49
|
+
let current = new URL(url)
|
|
50
|
+
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
|
51
|
+
if (!allowedDownloadUrl(current)) throw new Error('release download URL rejected')
|
|
52
|
+
const response = await fetchImpl(current, {redirect: 'manual'})
|
|
53
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) return response
|
|
54
|
+
const location = response.headers.get('location')
|
|
55
|
+
if (location === null || redirects === MAX_REDIRECTS) {
|
|
56
|
+
throw new Error('release download redirect rejected')
|
|
57
|
+
}
|
|
58
|
+
current = new URL(location, current)
|
|
59
|
+
}
|
|
60
|
+
throw new Error('release download redirect rejected')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function responseBytes(response, maxBytes) {
|
|
64
|
+
if (!response.ok || response.body === null) {
|
|
65
|
+
throw new Error(`release download failed: HTTP ${response.status}`)
|
|
66
|
+
}
|
|
67
|
+
const declared = Number(response.headers.get('content-length'))
|
|
68
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
69
|
+
throw new Error('release download exceeded size limit')
|
|
70
|
+
}
|
|
71
|
+
const chunks = []
|
|
72
|
+
let total = 0
|
|
73
|
+
for await (const value of response.body) {
|
|
74
|
+
const chunk = Buffer.from(value)
|
|
75
|
+
total += chunk.length
|
|
76
|
+
if (total > maxBytes) throw new Error('release download exceeded size limit')
|
|
77
|
+
chunks.push(chunk)
|
|
78
|
+
}
|
|
79
|
+
return Buffer.concat(chunks)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseChecksum(text, artifact) {
|
|
83
|
+
const expectedName = basename(artifact)
|
|
84
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
85
|
+
const match = /^([a-fA-F0-9]{64})(?:\s+[*]?(.+))?$/u.exec(line.trim())
|
|
86
|
+
if (match === null) continue
|
|
87
|
+
if (match[2] !== undefined && basename(match[2]) !== expectedName) continue
|
|
88
|
+
const digest = match[1].toLowerCase()
|
|
89
|
+
if (CHECKSUM_PATTERN.test(digest)) return digest
|
|
90
|
+
}
|
|
91
|
+
throw new Error('release checksum rejected')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function expectedChecksum(url, artifact, options) {
|
|
95
|
+
const response = await request(`${url}.sha256`, options)
|
|
96
|
+
const body = await responseBytes(response, MAX_CHECKSUM_BYTES)
|
|
97
|
+
return parseChecksum(body.toString('utf8'), artifact)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function downloadArtifact(url, destination, options) {
|
|
101
|
+
const response = await request(url, options)
|
|
102
|
+
if (!response.ok || response.body === null) {
|
|
103
|
+
throw new Error(`release download failed: HTTP ${response.status}`)
|
|
104
|
+
}
|
|
105
|
+
const declared = Number(response.headers.get('content-length'))
|
|
106
|
+
if (Number.isFinite(declared) && declared > MAX_ARTIFACT_BYTES) {
|
|
107
|
+
throw new Error('release download exceeded size limit')
|
|
108
|
+
}
|
|
109
|
+
const handle = await open(destination, 'wx', 0o600)
|
|
110
|
+
const hash = createHash('sha256')
|
|
111
|
+
let total = 0
|
|
112
|
+
try {
|
|
113
|
+
for await (const value of response.body) {
|
|
114
|
+
const chunk = Buffer.from(value)
|
|
115
|
+
total += chunk.length
|
|
116
|
+
if (total > MAX_ARTIFACT_BYTES) throw new Error('release download exceeded size limit')
|
|
117
|
+
hash.update(chunk)
|
|
118
|
+
await handle.write(chunk)
|
|
119
|
+
}
|
|
120
|
+
await handle.sync()
|
|
121
|
+
} catch (error) {
|
|
122
|
+
await handle.close().catch(() => {})
|
|
123
|
+
await rm(destination, {force: true})
|
|
124
|
+
throw error
|
|
125
|
+
}
|
|
126
|
+
await handle.close()
|
|
127
|
+
return hash.digest('hex')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sameDigest(left, right) {
|
|
131
|
+
if (!CHECKSUM_PATTERN.test(left) || !CHECKSUM_PATTERN.test(right)) return false
|
|
132
|
+
return timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex'))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function run(command, args) {
|
|
136
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
137
|
+
const child = spawn(command, args, {stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true})
|
|
138
|
+
let stderr = ''
|
|
139
|
+
child.stderr.on('data', chunk => {
|
|
140
|
+
if (stderr.length < 8192) stderr += String(chunk).slice(0, 8192 - stderr.length)
|
|
141
|
+
})
|
|
142
|
+
child.once('error', rejectRun)
|
|
143
|
+
child.once('exit', (code, signal) => {
|
|
144
|
+
if (signal !== null || code !== 0) {
|
|
145
|
+
rejectRun(new Error(`release extraction failed${stderr ? `: ${stderr.trim()}` : ''}`))
|
|
146
|
+
} else resolveRun()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function extractArtifact({artifact, payload, target, platform}) {
|
|
152
|
+
await mkdir(payload, {recursive: true, mode: 0o700})
|
|
153
|
+
if (target.archive === 'file') {
|
|
154
|
+
const executable = resolve(payload, target.executable)
|
|
155
|
+
await copyFile(artifact, executable)
|
|
156
|
+
await chmod(executable, 0o700)
|
|
157
|
+
return executable
|
|
158
|
+
}
|
|
159
|
+
if (platform === 'darwin') {
|
|
160
|
+
await run('/usr/bin/ditto', ['-x', '-k', artifact, payload])
|
|
161
|
+
} else {
|
|
162
|
+
await run(platform === 'win32' ? 'tar.exe' : 'tar', ['-xf', artifact, '-C', payload])
|
|
163
|
+
}
|
|
164
|
+
const executable = resolve(payload, target.executable)
|
|
165
|
+
const executableRelative = relative(resolve(payload), executable)
|
|
166
|
+
if (executableRelative === ''
|
|
167
|
+
|| executableRelative === '..'
|
|
168
|
+
|| executableRelative.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)
|
|
169
|
+
|| isAbsolute(executableRelative)) {
|
|
170
|
+
throw new Error('release executable path rejected')
|
|
171
|
+
}
|
|
172
|
+
const info = await lstat(executable)
|
|
173
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error('release executable rejected')
|
|
174
|
+
if (platform !== 'win32') await chmod(executable, 0o700)
|
|
175
|
+
return executable
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function executableReady(path, platform) {
|
|
179
|
+
try {
|
|
180
|
+
const info = await lstat(path)
|
|
181
|
+
if (!info.isFile() || info.isSymbolicLink()) return false
|
|
182
|
+
await access(path, platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK)
|
|
183
|
+
return true
|
|
184
|
+
} catch {
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function cachedInstallation({root, executable, target, platform, expected}) {
|
|
190
|
+
if (!await executableReady(executable, platform)) return null
|
|
191
|
+
try {
|
|
192
|
+
const receiptPath = join(root, 'novaaudio-install.json')
|
|
193
|
+
const details = await lstat(receiptPath)
|
|
194
|
+
if (!details.isFile() || details.isSymbolicLink() || details.size > INSTALL_RECEIPT_BYTES) {
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
const receipt = JSON.parse(await readFile(receiptPath, 'utf8'))
|
|
198
|
+
const keys = Object.keys(receipt).sort()
|
|
199
|
+
if (keys.join('\0') !== ['artifact', 'schema_version', 'sha256', 'target', 'version'].join('\0')
|
|
200
|
+
|| receipt.schema_version !== 1
|
|
201
|
+
|| receipt.version !== PRODUCT_VERSION
|
|
202
|
+
|| receipt.target !== target.id
|
|
203
|
+
|| receipt.artifact !== target.artifact
|
|
204
|
+
|| !CHECKSUM_PATTERN.test(receipt.sha256)
|
|
205
|
+
|| expected !== undefined && !sameDigest(receipt.sha256, expected)) return null
|
|
206
|
+
return Object.freeze({sha256: receipt.sha256})
|
|
207
|
+
} catch {
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function lockOwnerAlive(owner, kill = process.kill) {
|
|
213
|
+
if (owner === null || typeof owner !== 'object' || !Number.isSafeInteger(owner.pid)
|
|
214
|
+
|| owner.pid < 1 || typeof owner.token !== 'string' || owner.token.length !== 36) return false
|
|
215
|
+
try {
|
|
216
|
+
kill(owner.pid, 0)
|
|
217
|
+
return true
|
|
218
|
+
} catch (error) {
|
|
219
|
+
return error?.code !== 'ESRCH'
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function installPayloadAtomically(payload, root) {
|
|
224
|
+
const previous = `${root}.previous-${randomUUID()}`
|
|
225
|
+
let hadPrevious = false
|
|
226
|
+
try {
|
|
227
|
+
await rename(root, previous)
|
|
228
|
+
hadPrevious = true
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error?.code !== 'ENOENT') throw error
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
await rename(payload, root)
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (hadPrevious) {
|
|
236
|
+
await rename(previous, root).catch(() => {})
|
|
237
|
+
}
|
|
238
|
+
throw error
|
|
239
|
+
}
|
|
240
|
+
if (hadPrevious) await rm(previous, {recursive: true, force: true})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function readLockOwner(path) {
|
|
244
|
+
try {
|
|
245
|
+
const ownerPath = join(path, 'owner.json')
|
|
246
|
+
const details = await lstat(ownerPath)
|
|
247
|
+
if (!details.isFile() || details.isSymbolicLink() || details.size > 1024) return null
|
|
248
|
+
return JSON.parse(await readFile(ownerPath, 'utf8'))
|
|
249
|
+
} catch {
|
|
250
|
+
return null
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function acquireLock(path, {now = () => Date.now(), delay = wait} = {}) {
|
|
255
|
+
const deadline = now() + LOCK_WAIT_MS
|
|
256
|
+
for (;;) {
|
|
257
|
+
try {
|
|
258
|
+
await mkdir(path, {mode: 0o700})
|
|
259
|
+
const token = randomUUID()
|
|
260
|
+
try {
|
|
261
|
+
await writeFile(join(path, 'owner.json'), `${JSON.stringify({pid: process.pid, token})}\n`, {
|
|
262
|
+
mode: 0o600,
|
|
263
|
+
})
|
|
264
|
+
} catch (error) {
|
|
265
|
+
await rm(path, {recursive: true, force: true})
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
268
|
+
const heartbeat = setInterval(async () => {
|
|
269
|
+
const owner = await readLockOwner(path)
|
|
270
|
+
if (owner?.token !== token) return
|
|
271
|
+
const timestamp = new Date()
|
|
272
|
+
await utimes(path, timestamp, timestamp).catch(() => {})
|
|
273
|
+
}, LOCK_HEARTBEAT_MS)
|
|
274
|
+
heartbeat.unref()
|
|
275
|
+
return async () => {
|
|
276
|
+
clearInterval(heartbeat)
|
|
277
|
+
const owner = await readLockOwner(path)
|
|
278
|
+
if (owner?.token === token) await rm(path, {recursive: true, force: true})
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (error?.code !== 'EEXIST') throw error
|
|
282
|
+
const details = await stat(path).catch(() => null)
|
|
283
|
+
const owner = await readLockOwner(path)
|
|
284
|
+
if (details !== null && now() - details.mtimeMs > LOCK_STALE_MS
|
|
285
|
+
&& !lockOwnerAlive(owner)) {
|
|
286
|
+
await rm(path, {recursive: true, force: true})
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
if (now() >= deadline) throw new Error('desktop install is locked by another process')
|
|
290
|
+
await delay(REDIRECT_DELAY_MS)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function wait(milliseconds) {
|
|
296
|
+
return new Promise(resolveWait => setTimeout(resolveWait, milliseconds))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function ensureDesktop({
|
|
300
|
+
platform = process.platform,
|
|
301
|
+
arch = process.arch,
|
|
302
|
+
home,
|
|
303
|
+
fetchImpl = fetch,
|
|
304
|
+
extractImpl = extractArtifact,
|
|
305
|
+
baseUrl = releaseBaseUrl(),
|
|
306
|
+
} = {}) {
|
|
307
|
+
const target = resolveTarget(platform, arch)
|
|
308
|
+
const root = releaseRoot({home, target})
|
|
309
|
+
const executable = resolve(root, target.executable)
|
|
310
|
+
const cached = await cachedInstallation({root, executable, target, platform})
|
|
311
|
+
let expected
|
|
312
|
+
try {
|
|
313
|
+
expected = await expectedChecksum(
|
|
314
|
+
`${baseUrl}/${target.artifact}`,
|
|
315
|
+
target.artifact,
|
|
316
|
+
{fetchImpl},
|
|
317
|
+
)
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (cached !== null) return Object.freeze({target, root, executable})
|
|
320
|
+
throw error
|
|
321
|
+
}
|
|
322
|
+
if (cached !== null && sameDigest(cached.sha256, expected)) {
|
|
323
|
+
return Object.freeze({target, root, executable})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
await mkdir(dirname(root), {recursive: true, mode: 0o700})
|
|
327
|
+
const releaseLock = await acquireLock(`${root}.lock`)
|
|
328
|
+
try {
|
|
329
|
+
if (await cachedInstallation({root, executable, target, platform, expected}) !== null) {
|
|
330
|
+
return Object.freeze({target, root, executable})
|
|
331
|
+
}
|
|
332
|
+
const temporary = await mkdtemp(join(dirname(root), `.${target.id}-install-`))
|
|
333
|
+
try {
|
|
334
|
+
const artifact = join(temporary, target.artifact)
|
|
335
|
+
const artifactUrl = `${baseUrl}/${target.artifact}`
|
|
336
|
+
const actual = await downloadArtifact(artifactUrl, artifact, {fetchImpl})
|
|
337
|
+
if (!sameDigest(expected, actual)) throw new Error('release checksum mismatch')
|
|
338
|
+
const payload = join(temporary, 'payload')
|
|
339
|
+
await extractImpl({artifact, payload, target, platform})
|
|
340
|
+
await writeFile(join(payload, 'novaaudio-install.json'), `${JSON.stringify({
|
|
341
|
+
schema_version: 1,
|
|
342
|
+
version: PRODUCT_VERSION,
|
|
343
|
+
target: target.id,
|
|
344
|
+
artifact: target.artifact,
|
|
345
|
+
sha256: actual,
|
|
346
|
+
})}\n`, {mode: 0o600})
|
|
347
|
+
await installPayloadAtomically(payload, root)
|
|
348
|
+
} finally {
|
|
349
|
+
await rm(temporary, {recursive: true, force: true})
|
|
350
|
+
}
|
|
351
|
+
} finally {
|
|
352
|
+
await releaseLock()
|
|
353
|
+
}
|
|
354
|
+
if (!await executableReady(executable, platform)) throw new Error('desktop install incomplete')
|
|
355
|
+
return Object.freeze({target, root, executable})
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function launchDesktop(executable, {
|
|
359
|
+
openSettings = false,
|
|
360
|
+
platform = process.platform,
|
|
361
|
+
environment = process.env,
|
|
362
|
+
spawnImpl = spawn,
|
|
363
|
+
launchGraceMs = LAUNCH_GRACE_MS,
|
|
364
|
+
} = {}) {
|
|
365
|
+
return new Promise((resolveLaunch, rejectLaunch) => {
|
|
366
|
+
let child
|
|
367
|
+
try {
|
|
368
|
+
child = spawnImpl(executable, openSettings ? ['--open-settings'] : [], {
|
|
369
|
+
detached: true,
|
|
370
|
+
stdio: 'ignore',
|
|
371
|
+
windowsHide: false,
|
|
372
|
+
env: platform === 'linux'
|
|
373
|
+
? {...environment, APPIMAGE_EXTRACT_AND_RUN: '1'}
|
|
374
|
+
: environment,
|
|
375
|
+
})
|
|
376
|
+
} catch {
|
|
377
|
+
rejectLaunch(new Error('desktop launch failed'))
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
let settled = false
|
|
381
|
+
let launchTimer
|
|
382
|
+
const fail = () => {
|
|
383
|
+
if (settled) return
|
|
384
|
+
settled = true
|
|
385
|
+
clearTimeout(launchTimer)
|
|
386
|
+
rejectLaunch(new Error('desktop launch failed'))
|
|
387
|
+
}
|
|
388
|
+
child.once('error', fail)
|
|
389
|
+
child.once('exit', (code, signal) => {
|
|
390
|
+
if (settled) return
|
|
391
|
+
if (code === 0 && signal === null) {
|
|
392
|
+
settled = true
|
|
393
|
+
clearTimeout(launchTimer)
|
|
394
|
+
resolveLaunch()
|
|
395
|
+
} else {
|
|
396
|
+
fail()
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
child.once('spawn', () => {
|
|
400
|
+
if (settled) return
|
|
401
|
+
launchTimer = setTimeout(() => {
|
|
402
|
+
if (settled) return
|
|
403
|
+
settled = true
|
|
404
|
+
child.unref()
|
|
405
|
+
resolveLaunch()
|
|
406
|
+
}, launchGraceMs)
|
|
407
|
+
})
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function findCodex(platform = process.platform) {
|
|
412
|
+
const command = platform === 'win32' ? 'where.exe' : 'which'
|
|
413
|
+
const result = spawnSync(command, ['codex'], {encoding: 'utf8', windowsHide: true})
|
|
414
|
+
return result.status === 0 && String(result.stdout).trim() !== ''
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function inspectDoctor({
|
|
418
|
+
platform = process.platform,
|
|
419
|
+
arch = process.arch,
|
|
420
|
+
home,
|
|
421
|
+
environment = process.env,
|
|
422
|
+
} = {}) {
|
|
423
|
+
let target
|
|
424
|
+
try {
|
|
425
|
+
target = resolveTarget(platform, arch)
|
|
426
|
+
} catch {
|
|
427
|
+
return Object.freeze({supported: false, platform: `${platform}-${arch}`})
|
|
428
|
+
}
|
|
429
|
+
const root = releaseRoot({home, target})
|
|
430
|
+
const executable = resolve(root, target.executable)
|
|
431
|
+
const settings = desktopSettingsPath({platform, home, environment})
|
|
432
|
+
let secretKeys = []
|
|
433
|
+
try {
|
|
434
|
+
const document = JSON.parse(await readFile(settings, 'utf8'))
|
|
435
|
+
if (document?.secrets && typeof document.secrets === 'object' && !Array.isArray(document.secrets)) {
|
|
436
|
+
secretKeys = Object.keys(document.secrets).sort()
|
|
437
|
+
}
|
|
438
|
+
} catch {}
|
|
439
|
+
return Object.freeze({
|
|
440
|
+
supported: true,
|
|
441
|
+
platform: target.id,
|
|
442
|
+
desktopReady: await cachedInstallation({root, executable, target, platform}) !== null,
|
|
443
|
+
settingsPresent: await access(settings).then(() => true, () => false),
|
|
444
|
+
configuredSecretKeys: Object.freeze(secretKeys),
|
|
445
|
+
codexPresent: findCodex(platform),
|
|
446
|
+
})
|
|
447
|
+
}
|
package/src/target.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const PRODUCT_VERSION = '0.1.0'
|
|
5
|
+
export const RELEASE_REPOSITORY = 'deepnovacore/NovaAudioAgent'
|
|
6
|
+
|
|
7
|
+
const DEFINITIONS = Object.freeze({
|
|
8
|
+
'darwin-arm64': Object.freeze({
|
|
9
|
+
artifact: `nova-audio-agent-${PRODUCT_VERSION}-macos-arm64-app.zip`,
|
|
10
|
+
executable: 'Nova Audio Agent Ambient Orb.app/Contents/MacOS/Nova Audio Agent Ambient Orb',
|
|
11
|
+
archive: 'zip',
|
|
12
|
+
}),
|
|
13
|
+
'win32-x64': Object.freeze({
|
|
14
|
+
artifact: `nova-audio-agent-${PRODUCT_VERSION}-windows-x64-portable.zip`,
|
|
15
|
+
executable: 'Nova Audio Agent Ambient Orb.exe',
|
|
16
|
+
archive: 'zip',
|
|
17
|
+
}),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export function resolveTarget(platform = process.platform, arch = process.arch) {
|
|
21
|
+
const id = `${platform}-${arch}`
|
|
22
|
+
const definition = DEFINITIONS[id]
|
|
23
|
+
if (definition === undefined) {
|
|
24
|
+
throw new Error(`unsupported platform: ${platform}-${arch}`)
|
|
25
|
+
}
|
|
26
|
+
return Object.freeze({id, ...definition})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function releaseBaseUrl(version = PRODUCT_VERSION) {
|
|
30
|
+
return `https://github.com/${RELEASE_REPOSITORY}/releases/download/v${version}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function releaseRoot({
|
|
34
|
+
home = homedir(),
|
|
35
|
+
version = PRODUCT_VERSION,
|
|
36
|
+
target = resolveTarget(),
|
|
37
|
+
} = {}) {
|
|
38
|
+
return join(home, '.nova-audio-agent', 'cli', 'releases', version, target.id)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function desktopSettingsPath({
|
|
42
|
+
platform = process.platform,
|
|
43
|
+
home = homedir(),
|
|
44
|
+
environment = process.env,
|
|
45
|
+
} = {}) {
|
|
46
|
+
const product = 'Nova Audio Agent Ambient Orb'
|
|
47
|
+
if (platform === 'darwin') {
|
|
48
|
+
return join(home, 'Library', 'Application Support', product, 'ambient-orb-settings.json')
|
|
49
|
+
}
|
|
50
|
+
if (platform === 'win32') {
|
|
51
|
+
const appData = environment.APPDATA || join(home, 'AppData', 'Roaming')
|
|
52
|
+
return join(appData, product, 'ambient-orb-settings.json')
|
|
53
|
+
}
|
|
54
|
+
const config = environment.XDG_CONFIG_HOME || join(home, '.config')
|
|
55
|
+
return join(config, product, 'ambient-orb-settings.json')
|
|
56
|
+
}
|