freecode-sh 0.1.0-alpha.42 → 0.1.0-rc.3
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 +4 -34
- package/check-platform.mjs +2 -20
- package/freecode.mjs +102 -182
- package/install-lock.mjs +153 -0
- package/package.json +19 -30
- package/release-trust.mjs +24 -0
- package/release-version.cjs +17 -0
- package/release.mjs +64 -0
- package/terminal-release.json +33 -0
- package/terminal-release.json.sig +0 -0
- package/install.ps1 +0 -683
- package/install.sh +0 -687
- package/npm-bootstrap-environment.mjs +0 -7
package/README.md
CHANGED
|
@@ -1,39 +1,9 @@
|
|
|
1
|
-
<p align="center"><img src="https://freecode.sh/icon.svg" width="128" alt="freecode"></p>
|
|
2
|
-
|
|
3
1
|
# freecode
|
|
4
2
|
|
|
5
|
-
the
|
|
6
|
-
|
|
7
|
-
This npm package is a small macOS and Windows bootstrapper. On first launch it
|
|
8
|
-
installs the matching native freecode release, verifies its publisher signature
|
|
9
|
-
and checksum, and then runs the native executable. Application code and prompts
|
|
10
|
-
are not shipped in the npm package.
|
|
11
|
-
|
|
12
|
-
Both `freecode` and `freecode-sh` launch the same freecode CLI.
|
|
13
|
-
|
|
14
|
-
The signed native `alpha` channel is the canonical current release. npm
|
|
15
|
-
`latest` mirrors that version; npm `alpha` is intentionally not used.
|
|
16
|
-
|
|
17
|
-
## Install
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
npm install -g freecode-sh
|
|
21
|
-
freecode --version
|
|
22
|
-
freecode doctor
|
|
23
|
-
freecode
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
For a reproducible clean install of this immutable release:
|
|
3
|
+
Install the interactive TUI with `npm install -g freecode-sh`, then run `freecode login` and `freecode [folder]`.
|
|
27
4
|
|
|
28
|
-
|
|
29
|
-
npm install -g freecode-sh@0.1.0-alpha.42
|
|
30
|
-
freecode --version
|
|
31
|
-
```
|
|
5
|
+
This package runs TUI 0.1.0-rc.3, the same signed binaries as the standalone download. Desktop releases independently. Requires Node 22.18 or later, Apple silicon macOS or Windows x64. Intel Mac, Linux and Windows ARM are not supported by this release.
|
|
32
6
|
|
|
33
|
-
|
|
34
|
-
freecode logout
|
|
35
|
-
npm uninstall -g freecode-sh
|
|
36
|
-
rm -rf ~/.local/share/freecode # macOS: optionally remove downloaded native versions
|
|
37
|
-
```
|
|
7
|
+
Updating npm replaces the npm-owned command. The first launch verifies and downloads this exact TUI release. No postinstall download or headless runner is used. Existing project folders and older installations are preserved. Sign in again; legacy conversation databases are not imported. If an older command takes precedence, remove it using its original installer and check your PATH.
|
|
38
8
|
|
|
39
|
-
freecode
|
|
9
|
+
Use `npm uninstall -g freecode-sh` to remove the command. Downloaded payloads are in `~/.freecode/terminal-npm`.
|
package/check-platform.mjs
CHANGED
|
@@ -1,20 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const requestedPlatform = process.env.FREECODE_INSTALL_OS
|
|
4
|
-
const platform = requestedPlatform === 'Darwin'
|
|
5
|
-
? 'darwin'
|
|
6
|
-
: requestedPlatform === 'Windows_NT'
|
|
7
|
-
? 'win32'
|
|
8
|
-
: process.platform
|
|
9
|
-
const architecture = process.env.FREECODE_INSTALL_ARCH || process.arch
|
|
10
|
-
const supportedHost = (
|
|
11
|
-
(platform === 'darwin' && ['arm64', 'x64'].includes(architecture))
|
|
12
|
-
|| (platform === 'win32' && architecture === 'x64')
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
if (!supportedHost) {
|
|
16
|
-
process.stderr.write(
|
|
17
|
-
`freecode: Free Code's native npm installer supports macOS arm64/x64 and Windows x64 (detected ${platform}/${architecture}).\n`,
|
|
18
|
-
)
|
|
19
|
-
process.exit(1)
|
|
20
|
-
}
|
|
1
|
+
import { targetFor } from './freecode.mjs'
|
|
2
|
+
targetFor()
|
package/freecode.mjs
CHANGED
|
@@ -1,199 +1,119 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { spawn } from 'node:child_process'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
// npm owns the command. This package downloads only its exact signed TUI release.
|
|
3
|
+
import { spawn, execFileSync } from 'node:child_process'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import { createReadStream, createWriteStream, realpathSync } from 'node:fs'
|
|
6
|
+
import { chmod, mkdir, mkdtemp, readFile, rename, rm, lstat, readdir, writeFile } from 'node:fs/promises'
|
|
6
7
|
import { homedir } from 'node:os'
|
|
7
|
-
import { dirname,
|
|
8
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
8
9
|
import { fileURLToPath } from 'node:url'
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const packageMetadata = JSON.parse(await readFile(join(packageDirectory, 'package.json'), 'utf8'))
|
|
10
|
+
import { pipeline } from 'node:stream/promises'
|
|
11
|
+
import { Transform } from 'node:stream'
|
|
12
|
+
import { signedTerminalManifest } from './release.mjs'
|
|
13
|
+
import { createRefreshLock } from './install-lock.mjs'
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|| releaseContract?.channel !== 'alpha'
|
|
20
|
-
|| releaseContract?.nativeVersion !== packageMetadata.version
|
|
21
|
-
) {
|
|
22
|
-
fail('The npm package metadata is invalid. Reinstall freecode-sh and try again.')
|
|
15
|
+
export function targetFor(platform = process.platform, arch = process.arch) {
|
|
16
|
+
if (platform === 'darwin' && arch === 'arm64') return 'darwin-arm64'
|
|
17
|
+
if (platform === 'win32' && arch === 'x64') return 'windows-x64'
|
|
18
|
+
throw new Error(`Freecode TUI currently supports Apple silicon macOS and Windows x64; detected ${platform}/${arch}. See https://freecode.sh/download.`)
|
|
23
19
|
}
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const requestedPlatform = process.env.FREECODE_INSTALL_OS
|
|
29
|
-
const platform = requestedPlatform === 'Darwin'
|
|
30
|
-
? 'darwin'
|
|
31
|
-
: requestedPlatform === 'Windows_NT'
|
|
32
|
-
? 'win32'
|
|
33
|
-
: process.platform
|
|
34
|
-
const architecture = process.env.FREECODE_INSTALL_ARCH || process.arch
|
|
35
|
-
const supportedHost = (
|
|
36
|
-
(platform === 'darwin' && ['arm64', 'x64'].includes(architecture))
|
|
37
|
-
|| (platform === 'win32' && architecture === 'x64')
|
|
38
|
-
)
|
|
39
|
-
if (!supportedHost) {
|
|
40
|
-
fail(`Free Code's native npm installer supports macOS arm64/x64 and Windows x64 (detected ${platform}/${architecture}).`)
|
|
41
|
-
}
|
|
42
|
-
const windows = platform === 'win32'
|
|
43
|
-
const defaultInstallRoot = windows
|
|
44
|
-
? join(process.env.LOCALAPPDATA || join(homeDirectory, 'AppData', 'Local'), 'Freecode')
|
|
45
|
-
: join(homeDirectory, '.local', 'share', 'freecode')
|
|
46
|
-
const installRoot = resolve(process.env.FREECODE_INSTALL_ROOT || defaultInstallRoot)
|
|
47
|
-
const recordedBinDirectory = await readFirstLine(join(installRoot, 'bin-dir'))
|
|
48
|
-
let binDirectory = resolve(
|
|
49
|
-
windows
|
|
50
|
-
? join(installRoot, 'bin')
|
|
51
|
-
: process.env.FREECODE_BIN_DIR || recordedBinDirectory || join(installRoot, 'npm-bin'),
|
|
52
|
-
)
|
|
53
|
-
let nativeExecutable = join(binDirectory, windows ? 'freecode.cmd' : 'freecode')
|
|
54
|
-
let nativeVersion = await managedNativeVersion(nativeExecutable, installRoot, platform)
|
|
55
|
-
|
|
56
|
-
if (nativeVersion && compareAlphaVersions(nativeVersion, packageMetadata.version) >= 0) {
|
|
57
|
-
process.exitCode = await runManaged(nativeExecutable, nativeVersion, process.argv.slice(2))
|
|
58
|
-
} else {
|
|
59
|
-
if (!windows && await sameFile(nativeExecutable, bootstrapPath)) {
|
|
60
|
-
binDirectory = join(installRoot, 'npm-bin')
|
|
61
|
-
nativeExecutable = join(binDirectory, 'freecode')
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
process.stderr.write(`Installing Free Code ${packageMetadata.version}…\n`)
|
|
65
|
-
const installer = windows
|
|
66
|
-
? {
|
|
67
|
-
command: 'powershell.exe',
|
|
68
|
-
args: ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(packageDirectory, 'install.ps1'), '-Channel', releaseContract.channel, '-Version', releaseContract.nativeVersion],
|
|
69
|
-
}
|
|
70
|
-
: { command: '/bin/sh', args: [join(packageDirectory, 'install.sh'), '--channel', releaseContract.channel, '--version', releaseContract.nativeVersion] }
|
|
71
|
-
const installerEnvironment = {
|
|
72
|
-
...process.env,
|
|
73
|
-
FREECODE_INSTALL_ROOT: installRoot,
|
|
74
|
-
FREECODE_BIN_DIR: binDirectory,
|
|
75
|
-
FREECODE_INSTALL_EMBEDDED: 'true',
|
|
76
|
-
}
|
|
77
|
-
const installStartedAt = Date.now()
|
|
78
|
-
const installStatus = await run(
|
|
79
|
-
installer.command,
|
|
80
|
-
installer.args,
|
|
81
|
-
windows ? withoutPowerShellModulePath(installerEnvironment) : installerEnvironment,
|
|
82
|
-
)
|
|
83
|
-
if (installStatus !== 0) process.exit(installStatus)
|
|
84
|
-
nativeVersion = await managedNativeVersion(nativeExecutable, installRoot, platform)
|
|
85
|
-
if (!nativeVersion || compareAlphaVersions(nativeVersion, packageMetadata.version) < 0) {
|
|
86
|
-
fail('The signed installer completed without creating a managed Free Code executable.')
|
|
87
|
-
}
|
|
88
|
-
await writeInstallTelemetryReceipt({
|
|
89
|
-
releaseId: packageMetadata.version,
|
|
90
|
-
platform: ['darwin', 'linux', 'win32'].includes(platform) ? platform : 'unknown',
|
|
91
|
-
durationMs: Math.min(86_400_000, Math.max(0, Date.now() - installStartedAt)),
|
|
92
|
-
})
|
|
93
|
-
process.exitCode = await runManaged(nativeExecutable, nativeVersion, process.argv.slice(2))
|
|
20
|
+
async function digest(file) {
|
|
21
|
+
const hash = createHash('sha256')
|
|
22
|
+
for await (const chunk of createReadStream(file)) hash.update(chunk)
|
|
23
|
+
return hash.digest('hex')
|
|
94
24
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
const target = await realpath(path)
|
|
106
|
-
const versionsDirectory = await realpath(resolve(root, 'versions'))
|
|
107
|
-
const relativeTarget = relative(versionsDirectory, target)
|
|
108
|
-
const parts = relativeTarget.split(sep)
|
|
109
|
-
if (
|
|
110
|
-
relativeTarget === '..' ||
|
|
111
|
-
relativeTarget.startsWith(`..${sep}`) ||
|
|
112
|
-
isAbsolute(relativeTarget) ||
|
|
113
|
-
parts.length !== 3 ||
|
|
114
|
-
parts[1] !== 'bin' ||
|
|
115
|
-
parts[2] !== 'freecode' ||
|
|
116
|
-
!/^\d+\.\d+\.\d+-alpha\.\d+$/.test(parts[0])
|
|
117
|
-
) {
|
|
118
|
-
return undefined
|
|
119
|
-
}
|
|
120
|
-
return parts[0]
|
|
121
|
-
} catch {
|
|
122
|
-
return undefined
|
|
25
|
+
async function verifyPayload(directory, manifest, target) {
|
|
26
|
+
const metadata = JSON.parse(await readFile(join(directory, 'terminal.json'), 'utf8'))
|
|
27
|
+
if (metadata.product !== 'freecode-terminal' || metadata.version !== manifest.version || metadata.engineVersion !== manifest.engineVersion || metadata.sourceRevision !== manifest.sourceRevision || !metadata.files || Object.keys(metadata.files).length < 6)
|
|
28
|
+
throw new Error('Installed TUI identity does not match its signed release')
|
|
29
|
+
const executable = target === 'windows-x64' ? 'bin/freecode.exe' : 'bin/freecode'
|
|
30
|
+
if (!metadata.files[executable]) throw new Error('TUI launcher is missing from its inventory')
|
|
31
|
+
for (const [name, expected] of Object.entries(metadata.files)) {
|
|
32
|
+
const path = resolve(directory, name)
|
|
33
|
+
if (!path.startsWith(directory + sep) || !/^[a-f0-9]{64}$/.test(expected) || await digest(path) !== expected) throw new Error(`TUI payload verification failed: ${name}`)
|
|
123
34
|
}
|
|
35
|
+
return join(directory, executable)
|
|
124
36
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
async function writeInstallTelemetryReceipt({ releaseId, platform: receiptPlatform, durationMs }) {
|
|
132
|
-
const configRoot = windows && process.env.APPDATA ? process.env.APPDATA : join(homeDirectory, '.config')
|
|
133
|
-
const directory = resolve(configRoot, 'freecode')
|
|
134
|
-
const path = join(directory, 'install-receipt.json')
|
|
135
|
-
try {
|
|
136
|
-
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
137
|
-
if ((await lstat(directory)).isSymbolicLink()) return
|
|
138
|
-
try {
|
|
139
|
-
if ((await lstat(path)).isSymbolicLink()) return
|
|
140
|
-
} catch (error) {
|
|
141
|
-
if (error?.code !== 'ENOENT') return
|
|
142
|
-
}
|
|
143
|
-
if (!windows) await chmod(directory, 0o700)
|
|
144
|
-
await writeFile(path, `${JSON.stringify({ version: 1, releaseId, platform: receiptPlatform, durationMs })}\n`, {
|
|
145
|
-
encoding: 'utf8',
|
|
146
|
-
mode: 0o600,
|
|
147
|
-
})
|
|
148
|
-
if (!windows) await chmod(path, 0o600)
|
|
149
|
-
} catch {
|
|
150
|
-
// Install telemetry is best effort and must never fail a verified install.
|
|
37
|
+
async function rejectLinks(directory) {
|
|
38
|
+
for (const entry of await readdir(directory)) {
|
|
39
|
+
const path = join(directory, entry), stat = await lstat(path)
|
|
40
|
+
if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) throw new Error('Links and special files are not supported in TUI archives')
|
|
41
|
+
if (stat.isDirectory()) await rejectLinks(path)
|
|
151
42
|
}
|
|
152
43
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
44
|
+
export async function installRelease(manifest, target, cacheRoot, { fetchImpl = fetch } = {}) {
|
|
45
|
+
const asset = manifest.assets[target]
|
|
46
|
+
if (!asset) throw new Error('This TUI release has no archive for this computer')
|
|
47
|
+
const root = resolve(cacheRoot, manifest.version, target)
|
|
48
|
+
await mkdir(root, { recursive: true })
|
|
49
|
+
const installed = join(root, asset.sha256)
|
|
50
|
+
const verifyInstalled = async () => {
|
|
51
|
+
const marker = JSON.parse(await readFile(join(installed, 'verified-release.json'), 'utf8'))
|
|
52
|
+
if (marker.sha256 !== asset.sha256) throw new Error('Installed release archive identity differs')
|
|
53
|
+
return verifyPayload(join(installed, 'terminal'), manifest, target)
|
|
159
54
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index]
|
|
164
|
-
}
|
|
165
|
-
return 0
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
async function sameFile(left, right) {
|
|
55
|
+
try { return await verifyInstalled() }
|
|
56
|
+
catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
57
|
+
const staging = await mkdtemp(join(root, '.download-'))
|
|
169
58
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
59
|
+
const response = await fetchImpl(asset.url, { redirect: 'error', signal: AbortSignal.timeout(300000) })
|
|
60
|
+
if (!response.ok || !response.body) throw new Error(`TUI download failed (${response.status})`)
|
|
61
|
+
const archive = join(staging, 'terminal.zip'), hash = createHash('sha256')
|
|
62
|
+
let size = 0
|
|
63
|
+
await pipeline(response.body, new Transform({ transform(chunk, _encoding, callback) {
|
|
64
|
+
size += chunk.length
|
|
65
|
+
if (size > asset.size) return callback(new Error('TUI archive exceeds its signed size'))
|
|
66
|
+
hash.update(chunk); callback(null, chunk)
|
|
67
|
+
} }), createWriteStream(archive, { flags: 'wx' }))
|
|
68
|
+
if (size !== asset.size || hash.digest('hex') !== asset.sha256) throw new Error('TUI archive does not match its signed digest')
|
|
69
|
+
// Both supported hosts ship bsdtar. Verify the archive before executing any payload.
|
|
70
|
+
const entries = execFileSync('tar', ['-tf', archive], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).trim().split(/\r?\n/)
|
|
71
|
+
if (!entries.length || entries.some(name => !name.startsWith('terminal/') || name.includes('\\') || name.split('/').some(part => part === '..' || part === '.') || name.includes(':')))
|
|
72
|
+
throw new Error('Unsafe TUI archive paths')
|
|
73
|
+
const listing = execFileSync('tar', ['-tvf', archive], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
|
|
74
|
+
if (listing.trim().split(/\r?\n/).some(line => !['-', 'd'].includes(line[0]) || line.includes(' link to '))) throw new Error('Links and special files are not supported in TUI archives')
|
|
75
|
+
const extract = join(staging, 'payload')
|
|
76
|
+
await mkdir(extract)
|
|
77
|
+
execFileSync('tar', ['-xf', archive, '-C', extract], { stdio: 'pipe' })
|
|
78
|
+
await rejectLinks(extract)
|
|
79
|
+
await verifyPayload(join(extract, 'terminal'), manifest, target)
|
|
80
|
+
if (target !== 'windows-x64') await chmod(join(extract, 'terminal/bin/freecode'), 0o755)
|
|
81
|
+
await writeFile(join(extract, 'verified-release.json'), JSON.stringify({ sha256: asset.sha256 }))
|
|
82
|
+
// Reuse the cross-process lock used by native auth. Activation is short;
|
|
83
|
+
// downloads happen outside it, and a crashed owner can be recovered safely.
|
|
84
|
+
return await createRefreshLock({ path: installed }).run(async () => {
|
|
85
|
+
// Another launcher may have completed installation while we downloaded.
|
|
86
|
+
try { return await verifyInstalled() }
|
|
87
|
+
catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
88
|
+
const previous = join(staging, 'incomplete')
|
|
89
|
+
let moved = false
|
|
90
|
+
try { await rename(installed, previous); moved = true }
|
|
91
|
+
catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
92
|
+
try { await rename(extract, installed) }
|
|
93
|
+
catch (error) { if (moved) await rename(previous, installed); throw error }
|
|
94
|
+
return verifyInstalled()
|
|
95
|
+
})
|
|
96
|
+
} finally { await rm(staging, { recursive: true, force: true }) }
|
|
174
97
|
}
|
|
175
|
-
|
|
176
|
-
|
|
98
|
+
export async function main({ platform = process.platform, arch = process.arch, home = homedir(), publicKey, fetchImpl = fetch } = {}) {
|
|
99
|
+
const directory = dirname(fileURLToPath(import.meta.url))
|
|
100
|
+
const pkg = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8'))
|
|
101
|
+
const manifest = signedTerminalManifest(await readFile(join(directory, 'terminal-release.json')), await readFile(join(directory, 'terminal-release.json.sig')), publicKey)
|
|
102
|
+
if (pkg.name !== 'freecode-sh' || pkg.version !== manifest.version) throw new Error('npm and TUI release identities differ')
|
|
103
|
+
const target = targetFor(platform, arch)
|
|
104
|
+
const executable = await installRelease(manifest, target, join(home, '.freecode/terminal-npm'), { fetchImpl })
|
|
105
|
+
const args = process.argv.slice(2)
|
|
106
|
+
// The compiled TUI owns login and argument validation, including non-TTY rejection.
|
|
107
|
+
const child = spawn(executable, args, { stdio: 'inherit', env: process.env })
|
|
108
|
+
const interrupt = () => child.kill('SIGINT'), terminate = () => child.kill('SIGTERM')
|
|
109
|
+
process.on('SIGINT', interrupt); process.on('SIGTERM', terminate)
|
|
177
110
|
try {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function run(command, args, environment = process.env) {
|
|
185
|
-
return new Promise((resolvePromise, reject) => {
|
|
186
|
-
const child = spawn(command, args, {
|
|
187
|
-
cwd: process.cwd(),
|
|
188
|
-
env: environment,
|
|
189
|
-
stdio: 'inherit',
|
|
111
|
+
await new Promise((resolve, reject) => {
|
|
112
|
+
child.once('error', reject)
|
|
113
|
+
child.once('exit', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); resolve() })
|
|
190
114
|
})
|
|
191
|
-
|
|
192
|
-
child.once('exit', (code, signal) => resolvePromise(code ?? (signal ? 1 : 0)))
|
|
193
|
-
}).catch((error) => fail(`Could not start ${command}: ${error.message}`))
|
|
115
|
+
} finally { process.off('SIGINT', interrupt); process.off('SIGTERM', terminate) }
|
|
194
116
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
process.stderr.write(`freecode: ${message}\n`)
|
|
198
|
-
process.exit(1)
|
|
117
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
118
|
+
main().catch(error => { console.error(error.message); process.exitCode = 1 })
|
|
199
119
|
}
|
package/install-lock.mjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// Two terminal tabs are two processes sharing one credential. Without a lock
|
|
6
|
+
// across them, both present the same one-time refresh token and the server sees
|
|
7
|
+
// exactly the replay that rotation exists to detect — signing out a user who did
|
|
8
|
+
// nothing wrong. In-process single-flight cannot see the other process, so the
|
|
9
|
+
// lock lives on the filesystem next to the credential.
|
|
10
|
+
|
|
11
|
+
const STALE_MS = 30_000
|
|
12
|
+
const RETRY_MS = 50
|
|
13
|
+
// Never hang a user's CLI on a lock. Refreshing without the lock is unsafe:
|
|
14
|
+
// presenting the same one-time token twice looks exactly like theft.
|
|
15
|
+
const TIMEOUT_MS = 10_000
|
|
16
|
+
|
|
17
|
+
export class RefreshLockError extends Error {
|
|
18
|
+
constructor(code = 'refresh_lock_unavailable') {
|
|
19
|
+
super(code)
|
|
20
|
+
this.name = 'RefreshLockError'
|
|
21
|
+
this.code = code
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createRefreshLock({
|
|
26
|
+
path,
|
|
27
|
+
staleMs = STALE_MS,
|
|
28
|
+
retryMs = RETRY_MS,
|
|
29
|
+
timeoutMs = TIMEOUT_MS,
|
|
30
|
+
now = Date.now,
|
|
31
|
+
isProcessAlive = processIsAlive,
|
|
32
|
+
} = {}) {
|
|
33
|
+
// A store without a filesystem path (an injected test double) gets in-process
|
|
34
|
+
// serialization only, which is all a single process needs.
|
|
35
|
+
if (!path) return Object.freeze({ run: (task) => task() })
|
|
36
|
+
const lockPath = `${path}.lock`
|
|
37
|
+
const ownerPath = join(lockPath, 'owner')
|
|
38
|
+
|
|
39
|
+
// 'acquired' | 'busy' | 'unavailable'. Rotation makes this lock a correctness
|
|
40
|
+
// precondition; failing retryably is safer than invalidating the token family.
|
|
41
|
+
function acquire() {
|
|
42
|
+
try {
|
|
43
|
+
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 })
|
|
44
|
+
} catch {
|
|
45
|
+
// Kept separate from the write below: a recursive mkdir also reports
|
|
46
|
+
// EEXIST when the directory path is an ordinary file, and reading that as
|
|
47
|
+
// "someone holds the lock" would spin until the timeout instead of going
|
|
48
|
+
// straight through.
|
|
49
|
+
return 'unavailable'
|
|
50
|
+
}
|
|
51
|
+
const instance = randomUUID()
|
|
52
|
+
const candidatePath = `${lockPath}.${process.pid}.${instance}.candidate`
|
|
53
|
+
try {
|
|
54
|
+
// Build a non-empty directory first, then atomically rename it into place.
|
|
55
|
+
// A populated directory cannot be replaced by another rename, which makes
|
|
56
|
+
// both acquisition and stale recovery safe across macOS, Linux, and Windows.
|
|
57
|
+
mkdirSync(candidatePath, { mode: 0o700 })
|
|
58
|
+
writeFileSync(join(candidatePath, 'owner'), `${process.pid}:${instance}\n`, {
|
|
59
|
+
encoding: 'utf8', mode: 0o600, flag: 'wx',
|
|
60
|
+
})
|
|
61
|
+
renameSync(candidatePath, lockPath)
|
|
62
|
+
return 'acquired'
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return existsSync(lockPath) || error?.code === 'EEXIST' || error?.code === 'ENOTEMPTY'
|
|
65
|
+
? 'busy'
|
|
66
|
+
: 'unavailable'
|
|
67
|
+
} finally {
|
|
68
|
+
try { rmSync(candidatePath, { recursive: true, force: true }) } catch {}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function staleLock() {
|
|
73
|
+
try {
|
|
74
|
+
if (now() - statSync(lockPath).mtimeMs <= staleMs) return false
|
|
75
|
+
const contents = readFileSync(ownerPath, 'utf8')
|
|
76
|
+
const owner = Number.parseInt(contents.split(':', 1)[0], 10)
|
|
77
|
+
// Age alone is not enough: a suspended process can still resume its
|
|
78
|
+
// refresh. Only a lock whose recorded owner is gone may be recovered.
|
|
79
|
+
if (Number.isSafeInteger(owner) && owner > 0 && isProcessAlive(owner)) return false
|
|
80
|
+
return { recoveryPath: recoveryPathFor(lockPath, contents) }
|
|
81
|
+
} catch {
|
|
82
|
+
// Released while we were looking; the next acquire settles it.
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function recover(stale) {
|
|
88
|
+
// This one rename both claims and removes the stale directory. The recovery
|
|
89
|
+
// directory remains populated, so a delayed waiter cannot rename a live
|
|
90
|
+
// replacement over it.
|
|
91
|
+
try {
|
|
92
|
+
renameSync(lockPath, stale.recoveryPath)
|
|
93
|
+
return 'recovered'
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return !existsSync(lockPath) || existsSync(stale.recoveryPath) || error?.code === 'ENOENT'
|
|
96
|
+
? 'claimed'
|
|
97
|
+
: 'unavailable'
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function release() {
|
|
102
|
+
// Windows can refuse removal while another handle is open. A stuck lock
|
|
103
|
+
// directory is broken by staleness, so a failed release must never surface
|
|
104
|
+
// to the caller as a refresh error.
|
|
105
|
+
try { rmSync(lockPath, { recursive: true, force: true }) } catch {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function run(task) {
|
|
109
|
+
const deadline = now() + timeoutMs
|
|
110
|
+
let held = false
|
|
111
|
+
while (now() < deadline) {
|
|
112
|
+
const state = acquire()
|
|
113
|
+
if (state === 'acquired') { held = true; break }
|
|
114
|
+
if (state === 'unavailable') throw new RefreshLockError()
|
|
115
|
+
// A crashed process leaves its lock behind. Break it once it is clearly
|
|
116
|
+
// older than any real refresh.
|
|
117
|
+
const stale = staleLock()
|
|
118
|
+
if (stale) {
|
|
119
|
+
const recovery = recover(stale)
|
|
120
|
+
if (recovery === 'unavailable') throw new RefreshLockError()
|
|
121
|
+
if (recovery !== 'recovered') await delay(retryMs)
|
|
122
|
+
} else await delay(retryMs)
|
|
123
|
+
}
|
|
124
|
+
if (!held) throw new RefreshLockError('refresh_lock_timeout')
|
|
125
|
+
try {
|
|
126
|
+
return await task()
|
|
127
|
+
} finally {
|
|
128
|
+
if (held) release()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return Object.freeze({ run, lockPath, ownerPath })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function recoveryPathFor(lockPath, contents) {
|
|
136
|
+
const identity = createHash('sha256').update(contents).digest('hex').slice(0, 32)
|
|
137
|
+
return `${lockPath}.${identity}.recovered`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function delay(ms) {
|
|
141
|
+
return new Promise((resolve) => { setTimeout(resolve, ms) })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function processIsAlive(pid) {
|
|
145
|
+
try {
|
|
146
|
+
process.kill(pid, 0)
|
|
147
|
+
return true
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// EPERM means the process exists but cannot be signalled. Only ESRCH is
|
|
150
|
+
// evidence that the recorded owner is gone.
|
|
151
|
+
return error?.code !== 'ESRCH'
|
|
152
|
+
}
|
|
153
|
+
}
|
package/package.json
CHANGED
|
@@ -1,52 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freecode-sh",
|
|
3
|
-
"version": "0.1.0-
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0-rc.3",
|
|
4
|
+
"description": "The free interactive coding agent for your terminal.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
|
-
"author": "freecode",
|
|
7
|
-
"homepage": "https://freecode.sh",
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/freecode-sh/freecode.git"
|
|
11
|
-
},
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/freecode-sh/freecode/issues"
|
|
14
|
-
},
|
|
15
|
-
"keywords": [
|
|
16
|
-
"ai",
|
|
17
|
-
"agent",
|
|
18
|
-
"coding-agent",
|
|
19
|
-
"cli",
|
|
20
|
-
"developer-tools"
|
|
21
|
-
],
|
|
22
6
|
"type": "module",
|
|
23
7
|
"bin": {
|
|
24
8
|
"freecode": "./freecode.mjs",
|
|
25
9
|
"freecode-sh": "./freecode.mjs"
|
|
26
10
|
},
|
|
27
|
-
"files": [
|
|
28
|
-
"check-platform.mjs",
|
|
29
|
-
"freecode.mjs",
|
|
30
|
-
"npm-bootstrap-environment.mjs",
|
|
31
|
-
"install.sh",
|
|
32
|
-
"install.ps1"
|
|
33
|
-
],
|
|
34
11
|
"engines": {
|
|
35
|
-
"node": ">=
|
|
12
|
+
"node": ">=22.18.0"
|
|
36
13
|
},
|
|
37
14
|
"os": [
|
|
38
15
|
"darwin",
|
|
39
16
|
"win32"
|
|
40
17
|
],
|
|
18
|
+
"cpu": [
|
|
19
|
+
"arm64",
|
|
20
|
+
"x64"
|
|
21
|
+
],
|
|
41
22
|
"scripts": {
|
|
42
23
|
"preinstall": "node check-platform.mjs"
|
|
43
24
|
},
|
|
25
|
+
"files": [
|
|
26
|
+
"install-lock.mjs",
|
|
27
|
+
"check-platform.mjs",
|
|
28
|
+
"freecode.mjs",
|
|
29
|
+
"release.mjs",
|
|
30
|
+
"release-trust.mjs",
|
|
31
|
+
"release-version.cjs",
|
|
32
|
+
"terminal-release.json",
|
|
33
|
+
"terminal-release.json.sig",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"homepage": "https://freecode.sh/download",
|
|
44
37
|
"publishConfig": {
|
|
45
38
|
"access": "public",
|
|
46
39
|
"tag": "latest"
|
|
47
|
-
},
|
|
48
|
-
"freecodeRelease": {
|
|
49
|
-
"channel": "alpha",
|
|
50
|
-
"nativeVersion": "0.1.0-alpha.42"
|
|
51
40
|
}
|
|
52
41
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createVerify } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const RELEASE_SIGNING_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
|
|
4
|
+
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA8so4sWArOV0GXihYvoSo
|
|
5
|
+
xDLI1yMoILnVfCjYQwe4CKQ9EC10JV30cntMJZWXvXZS6STqQGIjyWisa6Op4nId
|
|
6
|
+
OuXKDjaEcQIeftgZBzzyGrMmAMC3W57VAi3d+w/F2XnkXBaH3NOphfftF6Fxt3Q0
|
|
7
|
+
2CxN3PjuuFaOGytKsYF5uDLCvEb+MpzJlD3HZoBzhhMCAoY6wmCdlgaDxD20UFdy
|
|
8
|
+
EigF5wwpcUWpYmbYMFp+6SLe/Fi8IrYirwiCv5eLur6PzFFcwFrJgUeZAdxmGuM8
|
|
9
|
+
eJzZlc7mK0+gzi8KpC2FZ9eOx1SafPE4An4+u8rZ7WA1Qk5eCAhNU9lm+JAeeKcC
|
|
10
|
+
XzhQpPUafybJg75dIbIayOC3tf5mn+X+Vl5+uY19nT2qcJsjgUeIcqbMs9FGXiIA
|
|
11
|
+
2G292N5WppyiDcx8yAIG6rOrAUGzHMYCrhvxQLAx2KP5QfC53VZ3La7yZOATY5ic
|
|
12
|
+
F78qSSBrKDrT42vinbosbX++Wr34YB6pXhiZF6jHPHrxAgMBAAE=
|
|
13
|
+
-----END PUBLIC KEY-----`
|
|
14
|
+
|
|
15
|
+
export function verifyReleaseSignature(payload, signature, publicKey = RELEASE_SIGNING_PUBLIC_KEY_PEM) {
|
|
16
|
+
try {
|
|
17
|
+
const verifier = createVerify('SHA256')
|
|
18
|
+
verifier.update(payload)
|
|
19
|
+
verifier.end()
|
|
20
|
+
return verifier.verify(publicKey, signature)
|
|
21
|
+
} catch {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Display versions and native build numbers have independent lifecycles.
|
|
2
|
+
const pattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(alpha|rc)\.(0|[1-9]\d*))?$/
|
|
3
|
+
function releaseVersion(version) {
|
|
4
|
+
if (typeof version !== 'string' || !pattern.test(version)) throw new Error('Use a release version such as 0.1.0-alpha.53, 0.1.0-rc.1 or 0.1.0.')
|
|
5
|
+
return version
|
|
6
|
+
}
|
|
7
|
+
function nativeVersions(version, buildNumber) {
|
|
8
|
+
releaseVersion(version)
|
|
9
|
+
const n = Number(buildNumber)
|
|
10
|
+
if (!/^[1-9]\d*$/.test(String(buildNumber)) || !Number.isSafeInteger(n) || n > 999999)
|
|
11
|
+
throw new Error('Pass --build-number: an increasing release sequence between 1 and 999999.')
|
|
12
|
+
// Start above legacy 0.x build numbers. All components respect Apple's limits
|
|
13
|
+
// (four digits, two digits, two digits) and Windows' 16-bit component limits.
|
|
14
|
+
const build = [1 + Math.floor(n / 10000), Math.floor(n / 100) % 100, n % 100]
|
|
15
|
+
return { short: version.split('-')[0], mac: build.join('.'), windows: [build[0], 0, ...build.slice(1)].join('.'), buildNumber: n }
|
|
16
|
+
}
|
|
17
|
+
module.exports = { releaseVersion, nativeVersions }
|