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 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 free coding agent
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
- ```bash
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
- ```bash
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 supports Apple Silicon and Intel Macs, plus Windows x64.
9
+ Use `npm uninstall -g freecode-sh` to remove the command. Downloaded payloads are in `~/.freecode/terminal-npm`.
@@ -1,20 +1,2 @@
1
- #!/usr/bin/env node
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 { constants } from 'node:fs'
5
- import { access, chmod, lstat, mkdir, readFile, realpath, writeFile } from 'node:fs/promises'
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, isAbsolute, join, relative, resolve, sep } from 'node:path'
8
+ import { dirname, join, resolve, sep } from 'node:path'
8
9
  import { fileURLToPath } from 'node:url'
9
- import { withoutPowerShellModulePath } from './npm-bootstrap-environment.mjs'
10
-
11
- const packageDirectory = dirname(fileURLToPath(import.meta.url))
12
- const bootstrapPath = fileURLToPath(import.meta.url)
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
- const releaseContract = packageMetadata.freecodeRelease
16
- if (
17
- packageMetadata.name !== 'freecode-sh'
18
- || !/^\d+\.\d+\.\d+-alpha\.\d+$/.test(packageMetadata.version)
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 homeDirectory = homedir()
26
- if (!homeDirectory) fail('A home directory is required to install Free Code.')
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
- async function managedNativeVersion(path, root, currentPlatform) {
97
- try {
98
- await access(path, currentPlatform === 'win32' ? constants.F_OK : constants.X_OK)
99
- if (currentPlatform === 'win32') {
100
- const version = await readFirstLine(join(root, 'current-version'))
101
- if (!/^\d+\.\d+\.\d+-alpha\.\d+$/.test(version || '')) return undefined
102
- await access(join(root, 'versions', version, 'bin', 'freecode.exe'), constants.F_OK)
103
- return version
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
- function runManaged(path, version, args) {
127
- const command = windows ? join(installRoot, 'versions', version, 'bin', 'freecode.exe') : path
128
- return run(command, args)
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
- function compareAlphaVersions(left, right) {
155
- const parse = (version) => {
156
- const match = version.match(/^(\d+)\.(\d+)\.(\d+)-alpha\.(\d+)$/)
157
- if (!match) fail(`The managed Free Code version is invalid: ${version}`)
158
- return match.slice(1).map(Number)
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
- const leftParts = parse(left)
161
- const rightParts = parse(right)
162
- for (let index = 0; index < leftParts.length; index += 1) {
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
- return (await realpath(left)) === (await realpath(right))
171
- } catch {
172
- return false
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
- async function readFirstLine(path) {
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
- return (await readFile(path, 'utf8')).split(/\r?\n/, 1)[0]?.trim() || undefined
179
- } catch {
180
- return undefined
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
- child.once('error', reject)
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
- function fail(message) {
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
  }
@@ -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-alpha.42",
4
- "description": "A free coding agent for your terminal.",
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": ">=18.18.0"
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 }