stateport 0.0.0-stage → 1.0.0-rc.6
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 +21 -0
- package/README.md +31 -2
- package/bin/stateport.mjs +153 -0
- package/lib/archive.mjs +142 -0
- package/lib/cli-output.mjs +159 -0
- package/lib/controls.mjs +23 -0
- package/lib/install.mjs +233 -0
- package/lib/launcher.mjs +55 -0
- package/lib/release.mjs +154 -0
- package/lib/session.mjs +55 -0
- package/lib/update-job.mjs +16 -0
- package/lib/update-policy.mjs +66 -0
- package/lib/updates.mjs +206 -0
- package/package.json +33 -4
- package/release.json +9 -0
- package/update-release.json +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StatePort contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# StatePort
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Save a browser reproduction as a State Card, verify a fix against the same
|
|
4
|
+
conditions, and reopen it in a later coding-agent session.
|
|
5
|
+
|
|
6
|
+
Requires an existing Node.js 24.x/npm installation. Install with `npx stateport`
|
|
7
|
+
(PowerShell: `npx.cmd stateport`). The transparent bootstrap verifies and downloads
|
|
8
|
+
the platform runtime and configures the detected supported coding agent.
|
|
9
|
+
Desktop is optional. Capture uses a separate StatePort-owned browser profile.
|
|
10
|
+
The system-browser runtime excludes Chromium; the pinned browser fallback is a
|
|
11
|
+
separate explicit, integrity-checked download. Developer execution requires
|
|
12
|
+
valid StatePort Developer access.
|
|
13
|
+
|
|
14
|
+
Documentation: https://docs.stateport.dev/
|
|
15
|
+
Source: https://github.com/StatePort-Dev/stateport-agent-skills/tree/main/bootstrap
|
|
16
|
+
|
|
17
|
+
## What happens next
|
|
18
|
+
|
|
19
|
+
The terminal shows download progress, a component checklist (runtime, browser,
|
|
20
|
+
skills and coding-agent connection), and concrete next steps with an example
|
|
21
|
+
request for your agent. Existing edited/foreign settings are preserved; a setup
|
|
22
|
+
conflict is clearly marked incomplete and exits with code 1. Runtime installation
|
|
23
|
+
and host tool discovery are separate outcomes.
|
|
24
|
+
|
|
25
|
+
Use `npx stateport --json` for scripts or an authorized same-chat agent setup.
|
|
26
|
+
It returns the exact verified `currentTask` launcher descriptor. Human text is
|
|
27
|
+
not a machine protocol. MCP/task-session remain machine-only automatically.
|
|
28
|
+
`--help` and `--version` do not install anything. `NO_COLOR` disables colour;
|
|
29
|
+
redirected output is plain text.
|
|
30
|
+
|
|
31
|
+
Bootstrap rc.6 delivers these terminal improvements while retaining signed
|
|
32
|
+
runtime rc.5 and skills beta.82. No runtime or browser artifact is replaced.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { installCandidate, readCurrent, withInstallTransaction } from '../lib/install.mjs';
|
|
7
|
+
import { installPublishedRelease } from '../lib/release.mjs';
|
|
8
|
+
import { readUpdatePolicy, setUpdatePolicy } from '../lib/update-policy.mjs';
|
|
9
|
+
import { updateStatus, checkForUpdates, selectSessionRuntime, rollbackGeneration } from '../lib/updates.mjs';
|
|
10
|
+
import { updateControl } from '../lib/controls.mjs';
|
|
11
|
+
import { ensureLauncher } from '../lib/launcher.mjs';
|
|
12
|
+
import { createCliOutput, installNeedsAttention } from '../lib/cli-output.mjs';
|
|
13
|
+
|
|
14
|
+
const rawArgs = process.argv.slice(2);
|
|
15
|
+
const protocolCommands = new Set(['mcp', 'task-session', 'task-start', 'task-call', 'task-tools', 'task-close']);
|
|
16
|
+
const output = createCliOutput({ json: rawArgs.includes('--json') || protocolCommands.has(rawArgs.find(arg => arg !== '--json')) });
|
|
17
|
+
const write = (value, kind) => output.emit(value, kind);
|
|
18
|
+
|
|
19
|
+
function cacheRoot() {
|
|
20
|
+
const configured = process.platform === 'win32' ? process.env.LOCALAPPDATA : process.platform === 'linux' ? process.env.XDG_DATA_HOME : undefined;
|
|
21
|
+
const base = configured && isAbsolute(configured) ? configured
|
|
22
|
+
: process.platform === 'win32' ? join(homedir(), 'AppData', 'Local')
|
|
23
|
+
: process.platform === 'darwin' ? join(homedir(), 'Library', 'Caches') : join(homedir(), '.local', 'share');
|
|
24
|
+
return join(base, 'StatePort', 'agent-runtime');
|
|
25
|
+
}
|
|
26
|
+
async function run(runtime, args, capture = false) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const child = spawn(process.execPath, [runtime.entry, ...args], { stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', windowsHide: true });
|
|
29
|
+
let output = ''; let size = 0;
|
|
30
|
+
child.stdout?.on('data', chunk => { size += chunk.length; if (size > 1024 * 1024) child.kill(); else output += chunk; });
|
|
31
|
+
// No captured native/backend error strings enter bootstrap diagnostics.
|
|
32
|
+
child.stderr?.resume();
|
|
33
|
+
child.once('error', () => reject(Error('RUNTIME_UNAVAILABLE')));
|
|
34
|
+
child.once('close', code => {
|
|
35
|
+
if (code !== 0) { reject(Error('RUNTIME_FAILED')); return; }
|
|
36
|
+
if (!capture) { resolve(undefined); return; }
|
|
37
|
+
try { resolve(JSON.parse(output)); } catch { reject(Error('RUNTIME_REPLY_INVALID')); }
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async function main() {
|
|
42
|
+
const args = rawArgs.filter(arg => arg !== '--json');
|
|
43
|
+
if (rawArgs.filter(arg => arg === '--json').length > 1) throw Error('INVALID_ARGUMENTS');
|
|
44
|
+
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { output.help(); return; }
|
|
45
|
+
if (args.length === 1 && ['--version', '-v'].includes(args[0])) {
|
|
46
|
+
const metadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
47
|
+
if (rawArgs.includes('--json')) write({ version: metadata.version }); else process.stdout.write(metadata.version + '\n');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (Number(process.versions.node.split('.')[0]) !== 24) throw Error('NODE_24_LTS_CANDIDATE_REQUIRED');
|
|
51
|
+
if (!((process.platform === 'linux' || process.platform === 'win32') && process.arch === 'x64') && !(process.platform === 'darwin' && process.arch === 'arm64')) throw Error('PLATFORM_UNSUPPORTED');
|
|
52
|
+
const commands = new Set(['install', 'update', 'updates', 'repair-launcher', 'rollback', 'uninstall', 'browser-install', 'doctor', 'open', 'access', 'mcp', 'task-session', 'task-start', 'task-call', 'task-tools', 'task-close']);
|
|
53
|
+
if (args[0] && !args[0].startsWith('--') && !commands.has(args[0])) throw Error('INVALID_ARGUMENTS');
|
|
54
|
+
const command = commands.has(args[0]) ? args.shift() : 'install';
|
|
55
|
+
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { output.help(); return; }
|
|
56
|
+
const cache = cacheRoot();
|
|
57
|
+
const trust = JSON.parse(await readFile(new URL('../update-release.json', import.meta.url), 'utf8'));
|
|
58
|
+
if (command === 'repair-launcher') {
|
|
59
|
+
if (args.length !== 1 || args[0] !== '--confirm') throw Error('INVALID_ARGUMENTS');
|
|
60
|
+
const runtime = await readCurrent(cache);
|
|
61
|
+
if (runtime?.generation?.launcher !== 1) throw Error('LAUNCHER_REPAIR_UNSUPPORTED');
|
|
62
|
+
write({ status: 'launcher_repaired', launcher: await ensureLauncher(cache, trust, { repair: true }), userData: 'preserved', editedLoader: 'backed_up_if_changed' }); return;
|
|
63
|
+
}
|
|
64
|
+
if (command === 'updates') { write(await updateControl(cache, trust, args), 'updates'); return; }
|
|
65
|
+
if (['doctor', 'open', 'access', 'mcp', 'task-session', 'task-start', 'task-call', 'task-tools', 'task-close'].includes(command)) {
|
|
66
|
+
const runtime = await readCurrent(cache); if (!runtime) throw Error('RUNTIME_NOT_INSTALLED');
|
|
67
|
+
if (command === 'doctor') { write(await run(runtime, ['doctor', ...args], true), 'doctor'); return; }
|
|
68
|
+
if (runtime.generation) {
|
|
69
|
+
// Stable loader performs safe next-session resolution. Read-only and
|
|
70
|
+
// existing task-call/close commands never trigger update checks.
|
|
71
|
+
const loader = join(cache, 'launcher', 'main.cjs');
|
|
72
|
+
await run({ entry: loader }, [command, ...args]); return;
|
|
73
|
+
}
|
|
74
|
+
await run(runtime, [command, ...args]); return;
|
|
75
|
+
}
|
|
76
|
+
if (command === 'uninstall') {
|
|
77
|
+
if (args.length) throw Error('INVALID_ARGUMENTS');
|
|
78
|
+
await withInstallTransaction(cache, async transaction => {
|
|
79
|
+
const runtime = await transaction.current();
|
|
80
|
+
if (runtime) {
|
|
81
|
+
const setup = await run(runtime, ['remove-agent-setup'], true);
|
|
82
|
+
if (setup.status !== 'removed') { write({ status: 'conflict', setup, userData: 'preserved' }); process.exitCode = 1; return; }
|
|
83
|
+
}
|
|
84
|
+
write(await transaction.uninstall());
|
|
85
|
+
}); return;
|
|
86
|
+
}
|
|
87
|
+
if (command === 'rollback') {
|
|
88
|
+
if (args.length) throw Error('INVALID_ARGUMENTS');
|
|
89
|
+
const policy = await readUpdatePolicy(cache);
|
|
90
|
+
if (policy.mode === 'pinned') throw Error('PIN_RELEASE_REQUIRED');
|
|
91
|
+
const runtime = await rollbackGeneration(cache);
|
|
92
|
+
const result = { status: 'rolled_back', runtime: runtime.productVersion, doctor: await run(runtime, ['doctor'], true), setup: await run(runtime, ['setup'], true), hostDiscovered: 'unverified' };
|
|
93
|
+
write(result); if (installNeedsAttention(result)) process.exitCode = 1; return;
|
|
94
|
+
}
|
|
95
|
+
let source, manifestSha256, agent, offline = false, confirmDownload, consentUpdates = false;
|
|
96
|
+
while (args.length) {
|
|
97
|
+
const option = args.shift();
|
|
98
|
+
if (option === '--offline' && !offline) { offline = true; continue; }
|
|
99
|
+
if (option === '--consent-updates' && !consentUpdates) { consentUpdates = true; continue; }
|
|
100
|
+
const value = args.shift();
|
|
101
|
+
if (!value) throw Error('INVALID_ARGUMENTS');
|
|
102
|
+
if (option === '--candidate-dir' && !source) source = value;
|
|
103
|
+
else if (option === '--manifest-sha256' && !manifestSha256) manifestSha256 = value;
|
|
104
|
+
else if (option === '--agent' && !agent && ['codex', 'claude', 'cursor'].includes(value)) agent = value;
|
|
105
|
+
else if (option === '--confirm-download' && !confirmDownload && /^[a-f0-9]{64}$/.test(value)) confirmDownload = value;
|
|
106
|
+
else throw Error('INVALID_ARGUMENTS');
|
|
107
|
+
}
|
|
108
|
+
if ((source && !manifestSha256) || (!source && manifestSha256) || (source && offline)) throw Error('INVALID_ARGUMENTS');
|
|
109
|
+
let runtime;
|
|
110
|
+
const policy = await readUpdatePolicy(cache);
|
|
111
|
+
if (policy.mode === 'pinned' && (command === 'update' || command === 'browser-install' || (manifestSha256 && manifestSha256 !== policy.pin.digest))) throw Error('PIN_RELEASE_REQUIRED');
|
|
112
|
+
if (command === 'update' && !source && !offline && trust.status === 'published') {
|
|
113
|
+
const outcome = await checkForUpdates(cache, trust, { force: true, manual: true, confirmDownload });
|
|
114
|
+
const selected = await selectSessionRuntime(cache, trust, { manual: true });
|
|
115
|
+
write({ status: 'update_checked', outcome, selected: { version: selected.productVersion, digest: selected.digest }, updates: await updateStatus(cache) }); return;
|
|
116
|
+
}
|
|
117
|
+
if (offline) {
|
|
118
|
+
runtime = await readCurrent(cache); if (!runtime) throw Error('OFFLINE_CACHE_UNAVAILABLE');
|
|
119
|
+
runtime = { ...runtime, unchanged: true };
|
|
120
|
+
} else if (!source) {
|
|
121
|
+
const release = JSON.parse(await readFile(new URL('../release.json', import.meta.url), 'utf8'));
|
|
122
|
+
if (release.status === 'unpublished') throw Error('PUBLIC_RUNTIME_RELEASE_UNPUBLISHED');
|
|
123
|
+
const current = await readCurrent(cache);
|
|
124
|
+
runtime = await installPublishedRelease(release, cache, fetch, { browserPreference: command === 'browser-install' || current?.browserPreference === 'pinned' ? 'pinned' : 'system', confirmDownload, onDownload: info => write({ status: 'downloading_verified_release', ...info }) });
|
|
125
|
+
if (runtime.status === 'browser_download_required') { write(runtime); return; }
|
|
126
|
+
}
|
|
127
|
+
write({ status: 'installing_candidate', permissions: ['copy verified runtime to user-owned versioned cache', 'configure unchanged owned agent skills/MCP'], trial: 'not_started' });
|
|
128
|
+
runtime ??= await installCandidate({ source, cache, manifestSha256 });
|
|
129
|
+
// Persist a quiet Notify default; repeat setup never changes existing policy.
|
|
130
|
+
if (consentUpdates) await setUpdatePolicy(cache, { mode: 'auto', consent: true });
|
|
131
|
+
else {
|
|
132
|
+
const { atomicJson } = await import('../lib/install.mjs');
|
|
133
|
+
await withInstallTransaction(cache, async () => atomicJson(cache, 'update-policy.json', await readUpdatePolicy(cache)));
|
|
134
|
+
}
|
|
135
|
+
const launcher = runtime.generation ? await ensureLauncher(cache, trust) : undefined;
|
|
136
|
+
const doctor = await run(runtime, ['doctor'], true);
|
|
137
|
+
const setup = await run(runtime, agent ? ['setup', '--agent', agent] : ['setup'], true);
|
|
138
|
+
const release = JSON.parse(await readFile(new URL('../release.json', import.meta.url), 'utf8'));
|
|
139
|
+
const result = { status: 'candidate_installed', version: runtime.productVersion, unchanged: runtime.unchanged, doctor, setup,
|
|
140
|
+
cache: offline ? 'verified_offline' : 'verified', hostDiscovered: 'unverified', updates: { mode: (await readUpdatePolicy(cache)).mode, delivery: 'candidate', launcher }, currentTask: {
|
|
141
|
+
command: process.execPath, args: [launcher ?? runtime.entry, 'task-session'], selection: 'verified_at_new_session_start', protocol: 'agent-task-session/1',
|
|
142
|
+
version: runtime.productVersion, digest: runtime.digest, generation: runtime.generation,
|
|
143
|
+
input: 'The installed version below is an offer; the ready frame binds the selected runtime at new-session start. Keep this host execution session and stdin open. First JSON line has project, scope, optional deadlineMs. Example for an originally authorized fix task: {"project":"/absolute/original/project","scope":{"userRequestedTask":true,"intent":"fix_verify","sourceOrigins":["http://127.0.0.1:3000"],"frontendOrigins":["http://127.0.0.1:3001"],"stateCardIds":[]},"deadlineMs":600000}. Replace project/origins/Card IDs from the original user task; installation grants no scope. Then JSON lines: operation tools; operation call with requestId/name/arguments; operation close.',
|
|
144
|
+
permissions: 'Use normal host approval for this exact runtime command when required. Installation does not grant browser, project or task permissions.',
|
|
145
|
+
}, published: !source && release.status === 'published' && release.version === runtime.productVersion };
|
|
146
|
+
write(result);
|
|
147
|
+
if (installNeedsAttention(result)) process.exitCode = 1;
|
|
148
|
+
}
|
|
149
|
+
void main().catch(error => {
|
|
150
|
+
const code = error instanceof Error && /^[A-Z0-9_]+$/.test(error.message) ? error.message : 'INSTALL_FAILED';
|
|
151
|
+
write({ status: 'blocked', code, nodePrerequisite: code.startsWith('NODE_') ? 'https://nodejs.org/en/download' : undefined });
|
|
152
|
+
process.exitCode = 1;
|
|
153
|
+
});
|
package/lib/archive.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const MAX_BYTES = 512 * 1024 * 1024;
|
|
5
|
+
export function validArchivePath(path, maximumLength = 1024) {
|
|
6
|
+
return typeof path === 'string' && path.length <= maximumLength && /^[A-Za-z0-9_.@ ()+-]+(?:\/[A-Za-z0-9_.@ ()+-]+)*$/.test(path)
|
|
7
|
+
&& path.split('/').every(part => part.length <= 255 && part !== '.' && part !== '..' && !/[. ]$/.test(part) && !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?: *\.| *$)/i.test(part));
|
|
8
|
+
}
|
|
9
|
+
function ustarParts(path) {
|
|
10
|
+
if (Buffer.byteLength(path) > 255) return undefined;
|
|
11
|
+
if (Buffer.byteLength(path) <= 100) return { name: path, prefix: '' };
|
|
12
|
+
let split = path.lastIndexOf('/');
|
|
13
|
+
while (split >= 0 && (Buffer.byteLength(path.slice(0, split)) > 155 || Buffer.byteLength(path.slice(split + 1)) > 100)) split = path.lastIndexOf('/', split - 1);
|
|
14
|
+
return split < 0 ? undefined : { prefix: path.slice(0, split), name: path.slice(split + 1) };
|
|
15
|
+
}
|
|
16
|
+
function pathHeader(path) {
|
|
17
|
+
const tail = ` path=${path}\n`; let size = Buffer.byteLength(tail) + 1;
|
|
18
|
+
while (String(size).length + Buffer.byteLength(tail) !== size) size = String(size).length + Buffer.byteLength(tail);
|
|
19
|
+
return Buffer.from(`${size}${tail}`);
|
|
20
|
+
}
|
|
21
|
+
function parsePathHeader(bytes) {
|
|
22
|
+
const space = bytes.indexOf(32), length = bytes.subarray(0, space).toString('utf8');
|
|
23
|
+
if (space < 1 || !/^[1-9][0-9]{0,3}$/.test(length) || Number(length) !== bytes.length || bytes.at(-1) !== 10 || bytes.subarray(space + 1, space + 6).toString('utf8') !== 'path=') throw Error('ARCHIVE_PAX');
|
|
24
|
+
const path = bytes.subarray(space + 6, -1).toString('utf8');
|
|
25
|
+
// One non-sticky path record for the verified browser closure only. No global,
|
|
26
|
+
// linkpath, size, uid, xattr, duplicate record or arbitrary metadata overrides.
|
|
27
|
+
if (!validArchivePath(path) || !/^bundled-browser\/browser\/chromium-[0-9]+\//.test(path) || ustarParts(path)) throw Error('ARCHIVE_PAX');
|
|
28
|
+
return path;
|
|
29
|
+
}
|
|
30
|
+
function requirePathHeaderSchema(files) {
|
|
31
|
+
const metadata = files.find(file => file.path === 'candidate.json');
|
|
32
|
+
try {
|
|
33
|
+
if (!metadata || !Buffer.isBuffer(metadata.bytes) || metadata.bytes.length > 1024 * 1024) throw Error();
|
|
34
|
+
const value = JSON.parse(metadata.bytes.toString('utf8'));
|
|
35
|
+
if (value.schemaVersion !== 'agent-runtime-candidate/1.2.0' || value.archiveFormat !== 'ustar+pax-path/1') throw Error();
|
|
36
|
+
} catch { throw Error('ARCHIVE_SCHEMA'); }
|
|
37
|
+
}
|
|
38
|
+
function field(header, offset, length) { return header.subarray(offset, offset + length).toString('utf8').replace(/\0.*$/s, ''); }
|
|
39
|
+
function octal(header, offset, length) {
|
|
40
|
+
const value = field(header, offset, length).trim();
|
|
41
|
+
if (!/^[0-7]+$/.test(value)) throw Error('ARCHIVE_NUMBER');
|
|
42
|
+
const result = Number.parseInt(value, 8); if (!Number.isSafeInteger(result)) throw Error('ARCHIVE_NUMBER'); return result;
|
|
43
|
+
}
|
|
44
|
+
function checksum(header) { return header.reduce((sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), 0); }
|
|
45
|
+
export function validateBundleLinks(files) {
|
|
46
|
+
const links = new Map(files.filter(file => file.kind === 'symlink').map(file => [file.path, file]));
|
|
47
|
+
const regular = files.filter(file => file.kind !== 'symlink').map(file => file.path);
|
|
48
|
+
function target(file) {
|
|
49
|
+
const prefix = file.path.split('/').slice(0, 3).join('/') + '/';
|
|
50
|
+
if (!/^bundled-browser\/browser\/chromium-[0-9]+\//.test(file.path) || typeof file.target !== 'string' || file.target.length > 100 || file.target.includes('\\') || posix.isAbsolute(file.target)) throw Error('ARCHIVE_LINK');
|
|
51
|
+
let concrete = false;
|
|
52
|
+
for (const part of file.target.split('/')) {
|
|
53
|
+
if (!part || ((part === '.' || part === '..') && concrete)) throw Error('ARCHIVE_LINK');
|
|
54
|
+
if (part !== '.' && part !== '..') concrete = true;
|
|
55
|
+
}
|
|
56
|
+
const resolved = posix.normalize(posix.join(posix.dirname(file.path), file.target));
|
|
57
|
+
if (!validArchivePath(resolved) || !resolved.startsWith(prefix)) throw Error('ARCHIVE_LINK');
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
function resolveLink(path, visited = new Set()) {
|
|
61
|
+
for (const [name, file] of links) if (path === name || path.startsWith(name + '/')) {
|
|
62
|
+
if (visited.has(name) || visited.size >= 64) throw Error('ARCHIVE_LINK');
|
|
63
|
+
return resolveLink(target(file) + path.slice(name.length), new Set([...visited, name]));
|
|
64
|
+
}
|
|
65
|
+
if (!regular.some(name => name === path || name.startsWith(path + '/'))) throw Error('ARCHIVE_LINK');
|
|
66
|
+
return path;
|
|
67
|
+
}
|
|
68
|
+
for (const file of links.values()) {
|
|
69
|
+
if (files.some(other => other.path.startsWith(file.path + '/'))) throw Error('ARCHIVE_LINK');
|
|
70
|
+
resolveLink(target(file), new Set([file.path]));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function decodeRuntimeArchive(compressed, options = {}) {
|
|
74
|
+
if (!Buffer.isBuffer(compressed) || compressed.length > MAX_BYTES) throw Error('ARCHIVE_SIZE');
|
|
75
|
+
const tar = gunzipSync(compressed, { maxOutputLength: MAX_BYTES });
|
|
76
|
+
if (tar.length % 512) throw Error('ARCHIVE_TRUNCATED');
|
|
77
|
+
const files = [], seen = new Set(); let offset = 0, pendingPath, usesPathHeaders = false, headers = 0;
|
|
78
|
+
while (offset < tar.length) {
|
|
79
|
+
const header = tar.subarray(offset, offset + 512); offset += 512;
|
|
80
|
+
if (header.every(byte => byte === 0)) {
|
|
81
|
+
if (pendingPath !== undefined) throw Error('ARCHIVE_PAX');
|
|
82
|
+
if (!tar.subarray(offset).every(byte => byte === 0)) throw Error('ARCHIVE_TRAILING_DATA');
|
|
83
|
+
if (usesPathHeaders) requirePathHeaderSchema(files);
|
|
84
|
+
validateBundleLinks(files); return files;
|
|
85
|
+
}
|
|
86
|
+
if (++headers > 8192) throw Error('ARCHIVE_SIZE');
|
|
87
|
+
if (octal(header, 148, 8) !== checksum(header)) throw Error('ARCHIVE_CHECKSUM');
|
|
88
|
+
const isLink = header[156] === 50, isPathHeader = header[156] === 120;
|
|
89
|
+
if (header[156] !== 0 && header[156] !== 48 && !(isLink && options.allowBundleLinks === true) && !(isPathHeader && options.allowPathHeaders === true)) throw Error('ARCHIVE_TYPE');
|
|
90
|
+
const prefix = field(header, 345, 155), name = field(header, 0, 100), physicalPath = prefix ? prefix + '/' + name : name;
|
|
91
|
+
if (!validArchivePath(physicalPath, 255)) throw Error('ARCHIVE_PATH');
|
|
92
|
+
const size = octal(header, 124, 12), mode = octal(header, 100, 8);
|
|
93
|
+
if (size > MAX_BYTES || offset + Math.ceil(size / 512) * 512 > tar.length) throw Error('ARCHIVE_SIZE');
|
|
94
|
+
if (isPathHeader) {
|
|
95
|
+
if (pendingPath !== undefined || size < 1 || size > 2048) throw Error('ARCHIVE_PAX');
|
|
96
|
+
pendingPath = parsePathHeader(tar.subarray(offset, offset + size)); usesPathHeaders = true;
|
|
97
|
+
offset += Math.ceil(size / 512) * 512; continue;
|
|
98
|
+
}
|
|
99
|
+
const path = pendingPath ?? physicalPath; pendingPath = undefined;
|
|
100
|
+
if (seen.has(path.toLowerCase())) throw Error('ARCHIVE_PATH'); seen.add(path.toLowerCase());
|
|
101
|
+
if (files.length >= 4096) throw Error('ARCHIVE_SIZE');
|
|
102
|
+
if (isLink) { if (size !== 0) throw Error('ARCHIVE_LINK'); files.push({ path, kind: 'symlink', target: field(header, 157, 100) }); continue; }
|
|
103
|
+
if (size > MAX_BYTES || offset + size > tar.length || files.length >= 4096) throw Error('ARCHIVE_SIZE');
|
|
104
|
+
files.push({ path, bytes: Buffer.from(tar.subarray(offset, offset + size)), ...(mode & 0o111 ? { executable: true } : {}) });
|
|
105
|
+
offset += Math.ceil(size / 512) * 512;
|
|
106
|
+
}
|
|
107
|
+
throw Error('ARCHIVE_TERMINATOR_MISSING');
|
|
108
|
+
}
|
|
109
|
+
export function encodeRuntimeArchive(files, options = {}) {
|
|
110
|
+
validateBundleLinks(files);
|
|
111
|
+
const chunks = [], seen = new Set(); let total = 1024, usesPathHeaders = false;
|
|
112
|
+
const append = (header, data) => {
|
|
113
|
+
const padding = Buffer.alloc((512 - data.length % 512) % 512);
|
|
114
|
+
total += 512 + data.length + padding.length; if (total > MAX_BYTES) throw Error('ARCHIVE_SIZE');
|
|
115
|
+
chunks.push(header, data, padding);
|
|
116
|
+
};
|
|
117
|
+
const makeHeader = (path, type, data, file = {}) => {
|
|
118
|
+
const parts = ustarParts(path); if (!parts) throw Error('ARCHIVE_PATH');
|
|
119
|
+
const header = Buffer.alloc(512); header.write(parts.name, 0); header.write(parts.prefix, 345);
|
|
120
|
+
const number = (value, offset, length) => { const text = value.toString(8).padStart(length - 1, '0'); if (text.length >= length) throw Error('ARCHIVE_SIZE'); header.write(text + '\0', offset); };
|
|
121
|
+
number(file.executable ? 0o755 : 0o644, 100, 8); number(0, 108, 8); number(0, 116, 8); number(data.length, 124, 12); number(0, 136, 12);
|
|
122
|
+
header[156] = type; if (type === 50) header.write(file.target, 157);
|
|
123
|
+
header.write('ustar\0', 257); header.write('00', 263);
|
|
124
|
+
header.write(checksum(header).toString(8).padStart(6, '0') + '\0 ', 148);
|
|
125
|
+
return header;
|
|
126
|
+
};
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
if (!validArchivePath(file.path) || seen.has(file.path.toLowerCase())) throw Error('ARCHIVE_PATH'); seen.add(file.path.toLowerCase());
|
|
129
|
+
const isLink = file.kind === 'symlink', data = isLink ? Buffer.alloc(0) : file.bytes;
|
|
130
|
+
if (!Buffer.isBuffer(data) || files.length > 4096) throw Error('ARCHIVE_SIZE');
|
|
131
|
+
let physicalPath = file.path;
|
|
132
|
+
if (!ustarParts(file.path)) {
|
|
133
|
+
if (options.allowPathHeaders !== true) throw Error('ARCHIVE_PATH');
|
|
134
|
+
const record = pathHeader(file.path); parsePathHeader(record); usesPathHeaders = true;
|
|
135
|
+
append(makeHeader('PaxHeaders/browser', 120, record), record);
|
|
136
|
+
physicalPath = 'Payload/browser';
|
|
137
|
+
}
|
|
138
|
+
append(makeHeader(physicalPath, isLink ? 50 : 48, data, file), data);
|
|
139
|
+
}
|
|
140
|
+
if (usesPathHeaders) requirePathHeaderSchema(files);
|
|
141
|
+
return gzipSync(Buffer.concat([...chunks, Buffer.alloc(1024)]));
|
|
142
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
|
|
3
|
+
const agents = { codex: 'Codex', claude: 'Claude Code', cursor: 'Cursor' };
|
|
4
|
+
const browsers = { chrome: 'Google Chrome', msedge: 'Microsoft Edge', chromium: 'Chromium' };
|
|
5
|
+
// Runtime/config strings are data, including when printed to a terminal.
|
|
6
|
+
export const terminalText = value => String(value ?? '').replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, '').slice(0, 4096);
|
|
7
|
+
const size = bytes => Number.isSafeInteger(bytes) && bytes > 0 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : 'size unavailable';
|
|
8
|
+
const version = value => /^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(value ?? '') ? value : 'unknown version';
|
|
9
|
+
const homePath = value => {
|
|
10
|
+
const text = terminalText(value), home = homedir();
|
|
11
|
+
return text.startsWith(home + '/') || text.startsWith(home + '\\') ? '~' + text.slice(home.length) : text;
|
|
12
|
+
};
|
|
13
|
+
export function installNeedsAttention(value) {
|
|
14
|
+
return value.setup?.status !== 'installed' || value.doctor?.browser?.status !== 'found';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const recovery = {
|
|
18
|
+
INVALID_ARGUMENTS: ['That command is not recognised.', 'Run npx stateport --help to see the available commands.'],
|
|
19
|
+
PLATFORM_UNSUPPORTED: ['This platform is not supported yet.', 'Use Windows x64, macOS Apple Silicon, or Linux x64.'],
|
|
20
|
+
NODE_24_LTS_CANDIDATE_REQUIRED: ['StatePort needs Node.js 24.', ['Install Node.js 24: https://nodejs.org/en/download', 'Run npx stateport again.']],
|
|
21
|
+
RUNTIME_NOT_INSTALLED: ['StatePort is not installed yet.', 'Run npx stateport to install it.'],
|
|
22
|
+
OFFLINE_CACHE_UNAVAILABLE: ['No verified offline installation was found.', 'Connect to the internet and run npx stateport once.'],
|
|
23
|
+
PUBLIC_RUNTIME_RELEASE_UNPUBLISHED: ['This build has no public runtime configured.', 'Use the official npm package: npx stateport.'],
|
|
24
|
+
PIN_RELEASE_REQUIRED: ['This installation is pinned to an exact version.', 'To allow changes, run npx stateport updates notify --release-pin.'],
|
|
25
|
+
UPDATE_INCOMPATIBLE: ['This update needs a reviewed setup.', 'Run npx stateport to review the new installation; restart your coding agent afterwards.'],
|
|
26
|
+
LAUNCHER_REPAIR_REQUIRED: ['The StatePort launcher needs repair.', 'Run npx stateport repair-launcher --confirm. Changed launcher files are backed up.'],
|
|
27
|
+
RECOVERY_REQUIRED: ['An interrupted setup needs attention.', 'Keep the existing files. Review npx stateport doctor --json before trying again.'],
|
|
28
|
+
INSTALL_BUSY: ['Another StatePort installation is in progress.', 'Let it finish, then run this command again.'],
|
|
29
|
+
INSTALL_LOCKED: ['Another StatePort installation may still be running.', 'Let it finish before retrying. Do not delete an active installation lock.'],
|
|
30
|
+
INSTALL_BUSY_OR_RECOVERY_REQUIRED: ['Another setup may be running or was interrupted.', 'Let any active setup finish. Keep the lock and existing files if recovery is needed.'],
|
|
31
|
+
ROLLBACK_UNAVAILABLE: ['No previous runtime is available to restore.', 'Keep the current installation, or run npx stateport to review the published version.'],
|
|
32
|
+
UPDATE_ROLLBACK_UNAVAILABLE: ['The previous runtime could not be verified.', 'Keep the current installation. Review npx stateport doctor --json.'],
|
|
33
|
+
RELEASE_HTTP: ['The runtime download could not be completed.', 'Check your connection and try npx stateport again.'],
|
|
34
|
+
RELEASE_REDIRECTS: ['The download destination could not be verified.', 'Try again later using npx stateport.'],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function createCliOutput({ json = false, stream = process.stdout, env = process.env, columns = stream.columns ?? 80 } = {}) {
|
|
38
|
+
const color = Boolean(stream.isTTY && env.TERM !== 'dumb' && env.NO_COLOR === undefined);
|
|
39
|
+
const paint = (code, text) => color ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
40
|
+
const width = Math.max(24, Math.min(80, Number(columns) || 80)) - 4;
|
|
41
|
+
let started = false;
|
|
42
|
+
const line = (text = '', style) => {
|
|
43
|
+
if (!text) { stream.write('\n'); return; }
|
|
44
|
+
// Wrap before colouring; text is printable, never executable terminal markup.
|
|
45
|
+
let remaining = terminalText(text);
|
|
46
|
+
while (remaining.length > width) {
|
|
47
|
+
let end = remaining.lastIndexOf(' ', width);
|
|
48
|
+
if (end < Math.floor(width / 3)) end = width;
|
|
49
|
+
const part = remaining.slice(0, end); stream.write(' ' + (style ? paint(style, part) : part) + '\n');
|
|
50
|
+
remaining = remaining.slice(end).trimStart();
|
|
51
|
+
}
|
|
52
|
+
stream.write(' ' + (style ? paint(style, remaining) : remaining) + '\n');
|
|
53
|
+
};
|
|
54
|
+
const heading = text => { line(); line(text, '1'); };
|
|
55
|
+
// Keep commands on one logical line; terminal soft wrapping stays copyable.
|
|
56
|
+
const command = text => stream.write(' ' + paint('36', terminalText(text)) + '\n');
|
|
57
|
+
const check = (ok, label, detail) => line(`${ok ? '✓' : '!'} ${label.padEnd(13)} ${terminalText(detail)}`, ok ? '32' : '33');
|
|
58
|
+
const next = (steps, title = 'Next') => { heading(title); steps.forEach((step, index) => line(`${index + 1}. ${step}`)); };
|
|
59
|
+
const begin = () => { if (json || started) return; started = true; line(); line('StatePort', '1;31'); line('Reproduce a bug. Verify the fix.', '2'); };
|
|
60
|
+
const footer = () => { line(); line('Capture is free. A Developer trial starts only when you choose it.', '2'); line(); };
|
|
61
|
+
function installation(value, title) {
|
|
62
|
+
const setup = value.setup ?? {}, result = setup.result ?? {}, agent = agents[setup.agent] ?? 'Coding agent';
|
|
63
|
+
const configured = setup.status === 'installed', browser = value.doctor?.browser;
|
|
64
|
+
const attention = installNeedsAttention(value);
|
|
65
|
+
heading(title ?? (attention ? 'Installed — setup needs attention' : value.unchanged ? 'Already installed' : 'Installed'));
|
|
66
|
+
check(true, 'Runtime', version(value.version) + (value.cache === 'verified_offline' ? ' · verified offline cache' : ' · verified'));
|
|
67
|
+
check(browser?.status === 'found', 'Browser', browser?.status === 'found' ? `${browsers[browser.channel] ?? 'System browser'} · found` : 'not found');
|
|
68
|
+
check(configured, 'Skills', configured ? `${version(result.version)} · ${result.unchanged ? 'up to date' : 'installed'}` : result.reason === 'CONFLICT' ? 'not updated · existing installation' : 'not configured');
|
|
69
|
+
check(configured, setup.agent ? `${agent} MCP` : 'Agent', configured ? 'configured' : setup.agent ? 'not configured' : 'not selected');
|
|
70
|
+
if (result.reason === 'CONFLICT') {
|
|
71
|
+
heading(`${agent} connection needs attention`);
|
|
72
|
+
line('Existing StatePort settings or skills conflict with this installation.');
|
|
73
|
+
line('Your existing setup has been preserved.');
|
|
74
|
+
if (setup.configuration?.path) line(`Review: ${homePath(setup.configuration.path)}`);
|
|
75
|
+
next([`Review the existing StatePort installation in ${agent}.`, 'To switch, remove the old setup using its original installer.', 'After removal, run npx stateport again.']);
|
|
76
|
+
} else if (result.reason === 'HOST_MISSING') {
|
|
77
|
+
next([`Install or open ${agent} in this user account.`, `Run npx stateport install --agent ${setup.agent} again.`]);
|
|
78
|
+
} else if (setup.status === 'guided_setup_required') {
|
|
79
|
+
heading('Choose your coding agent');
|
|
80
|
+
if (setup.reason === 'COPILOT_ADAPTER_REQUIRED') line('Copilot needs its documented MCP/skills setup.');
|
|
81
|
+
else line('No supported coding agent was detected in this user account.');
|
|
82
|
+
line('Run the command for the agent you use:');
|
|
83
|
+
for (const [key, name] of Object.entries(agents)) { line(name); command(`npx stateport install --agent ${key}`); }
|
|
84
|
+
} else if (!configured) {
|
|
85
|
+
heading(`${agent} setup could not finish`);
|
|
86
|
+
line(`Reason: ${terminalText(result.reason ?? 'SETUP_UNAVAILABLE')}`);
|
|
87
|
+
if (setup.configuration?.path) line(`Review: ${homePath(setup.configuration.path)}`);
|
|
88
|
+
next(['Review npx stateport doctor --json for diagnostics.', 'Resolve the reported issue, then run npx stateport again.']);
|
|
89
|
+
} else if (browser?.status !== 'found') {
|
|
90
|
+
next(['Install Google Chrome or Microsoft Edge on Windows.', 'Or run npx stateport browser-install to review the optional Chromium download.']);
|
|
91
|
+
} else {
|
|
92
|
+
next([setup.agent === 'cursor' ? 'Open Cursor and start a new chat.' : `Restart ${agent} and open a new chat.`, 'Give your agent the page URL and steps. For example:'], `Next in ${agent}`);
|
|
93
|
+
line(); line('“Use StatePort to reproduce this bug: <URL and steps>.”', '36');
|
|
94
|
+
line(); line('Your agent will ask for any permissions it needs.');
|
|
95
|
+
if (setup.agent === 'cursor') line('If StatePort is not listed, restart Cursor to reload its tools.');
|
|
96
|
+
}
|
|
97
|
+
footer();
|
|
98
|
+
}
|
|
99
|
+
function doctor(value) {
|
|
100
|
+
heading('Installation check'); check(Boolean(value.version), 'Runtime', version(value.version));
|
|
101
|
+
check(value.browser?.status === 'found', 'Browser', value.browser?.status === 'found' ? `${browsers[value.browser.channel] ?? 'System browser'} · found` : 'not found');
|
|
102
|
+
if (value.vault) check(['roundtrip_passed', 'available'].includes(value.vault.status), 'OS key store', terminalText(value.vault.status));
|
|
103
|
+
line(); line('For full diagnostics: npx stateport doctor --json'); line();
|
|
104
|
+
}
|
|
105
|
+
const emit = (value, kind = value.status) => {
|
|
106
|
+
if (json) { stream.write(JSON.stringify(value) + '\n'); return; }
|
|
107
|
+
begin();
|
|
108
|
+
if (kind === 'candidate_installed') { installation(value); return; }
|
|
109
|
+
if (kind === 'doctor') { doctor(value); return; }
|
|
110
|
+
if (kind === 'downloading_verified_release') { line(); line(`↓ Downloading ${value.browser === 'pinned_chromium' ? 'optional Chromium runtime' : 'StatePort runtime'} · ${size(value.bytes)}`); return; }
|
|
111
|
+
if (kind === 'installing_candidate') { line('Checking runtime and setting up your coding agent…', '2'); return; }
|
|
112
|
+
if (kind === 'browser_download_required') {
|
|
113
|
+
heading('Optional Chromium download'); line(`Download: ${size(value.bytes)}`);
|
|
114
|
+
line('Your current browser selection is unchanged.');
|
|
115
|
+
next(['Review the download size, then confirm its exact SHA-256:']);
|
|
116
|
+
command(`npx stateport browser-install --confirm-download ${terminalText(value.sha256)}`); line(); return;
|
|
117
|
+
}
|
|
118
|
+
if (kind === 'blocked') {
|
|
119
|
+
const code = terminalText(value.code);
|
|
120
|
+
const fallback = /INTEGRITY|SIGNATURE|RELEASE_KEY|MANIFEST|ARCHIVE|INVENTORY/.test(code)
|
|
121
|
+
? ['The runtime could not be verified.', 'Try again later using the official npx stateport command.']
|
|
122
|
+
: ['StatePort could not finish this command.', 'Run npx stateport doctor --json to inspect the installation.'];
|
|
123
|
+
const [title, step] = recovery[code] ?? fallback;
|
|
124
|
+
heading(title); next(Array.isArray(step) ? step : [step]); line(); line(`Error: ${code === 'NODE_24_LTS_CANDIDATE_REQUIRED' ? 'NODE_VERSION_UNSUPPORTED' : code}`, '2'); line(); return;
|
|
125
|
+
}
|
|
126
|
+
if (kind === 'uninstalled') { heading('StatePort connection removed'); check(true, 'User data', 'Cards and protected data preserved'); line('Verified runtime cache is retained for active processes.'); next(['Run npx stateport whenever you want to connect again.']); line(); return; }
|
|
127
|
+
if (kind === 'conflict') { heading('Uninstall stopped — existing setup needs review'); line('Edited or foreign settings were preserved.'); next(['Review the existing StatePort setup with its original installer.']); line(); return; }
|
|
128
|
+
if (kind === 'launcher_repaired') { heading('Launcher repaired'); line('Changed launcher files were backed up. User data is preserved.'); next(['Restart your coding agent.']); line(); return; }
|
|
129
|
+
if (kind === 'rolled_back') { installation({ ...value, version: value.runtime }, installNeedsAttention(value) ? 'Runtime restored — setup needs attention' : 'Previous runtime restored'); return; }
|
|
130
|
+
if (kind === 'update_checked') {
|
|
131
|
+
heading('Update check complete'); line(`Selected runtime: ${version(value.selected?.version)}`); line(updateMessage(value.outcome?.status));
|
|
132
|
+
if (value.outcome?.status === 'held') next(['Review npx stateport updates status --json for the exact hold reason.']);
|
|
133
|
+
else if (value.outcome?.status === 'pending_next_session') next(['Start a new agent session to use the selected runtime.']);
|
|
134
|
+
line(); return;
|
|
135
|
+
}
|
|
136
|
+
if (kind === 'updates') {
|
|
137
|
+
heading('Updates');
|
|
138
|
+
if (value.mode) line(`Mode: ${({ notify: 'Notify · ask before updating', auto: 'Auto · compatible updates only', pinned: 'Pinned · exact version' })[value.mode] ?? terminalText(value.mode)}`);
|
|
139
|
+
if (value.channel) line(`Channel: ${terminalText(value.channel)}`);
|
|
140
|
+
if (value.selected?.version) line(`Selected: ${version(value.selected.version)}`);
|
|
141
|
+
if (value.pending?.version) line(`Prepared: ${version(value.pending.version)} · for a new session`);
|
|
142
|
+
if (value.hold) line(`Attention: ${terminalText(value.hold)}`);
|
|
143
|
+
if (value.status) line(updateMessage(value.status));
|
|
144
|
+
line(); line('Details: npx stateport updates status --json'); line(); return;
|
|
145
|
+
}
|
|
146
|
+
heading('Command complete'); line('Details are available with --json.'); line();
|
|
147
|
+
};
|
|
148
|
+
return { begin, emit, help: () => {
|
|
149
|
+
if (json) { emit({ name: 'stateport', commands: ['install', 'doctor', 'update', 'updates', 'browser-install', 'rollback', 'uninstall', 'repair-launcher'], options: ['--json', '--help', '--version'] }); return; }
|
|
150
|
+
begin(); heading('Get started'); line('npx stateport'); line('Install StatePort and connect your coding agent.');
|
|
151
|
+
heading('Manage your installation');
|
|
152
|
+
for (const [command, description] of [['install --agent cursor', 'Choose Cursor, codex or claude'], ['doctor', 'Check runtime and browser'], ['update', 'Check for an update'], ['updates status', 'Show update policy'], ['browser-install', 'Review optional Chromium download'], ['rollback', 'Restore the retained previous runtime'], ['uninstall', 'Remove unchanged owned agent setup'], ['repair-launcher --confirm', 'Repair the launcher; keep a backup']]) line(`npx stateport ${command} — ${description}`);
|
|
153
|
+
heading('Options'); line('--json Machine-readable output for agents and scripts'); line('--offline Use a verified installation without downloading'); line('--version Show installer version'); line();
|
|
154
|
+
} };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function updateMessage(status) {
|
|
158
|
+
return ({ not_configured: 'No public update channel is configured for this build.', unavailable: 'The update check is unavailable. Existing installed work can continue.', not_installed: 'Run npx stateport first.', pinned: 'An exact version is pinned.', throttled: 'An update check ran recently.', up_to_date: 'You are up to date.', staged: 'An update is prepared for a new session.', pending_next_session: 'An update is prepared for a new session.', held: 'The update was not applied. Your current runtime is kept.', checked: 'Check complete. No update was selected.', update_available: 'An update is available for review.', retry_allowed: 'Retry is enabled for the selected version.', incompatible: 'This update needs reviewed setup.' })[status] ?? terminalText(status ?? 'No change');
|
|
159
|
+
}
|
package/lib/controls.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readInstalled } from './install.mjs';
|
|
2
|
+
import { readUpdatePolicy, setUpdatePolicy, hex } from './update-policy.mjs';
|
|
3
|
+
import { updateStatus, checkForUpdates, retryQuarantine } from './updates.mjs';
|
|
4
|
+
|
|
5
|
+
/** Shared transparent-bootstrap and verified installed-launcher CLI surface. */
|
|
6
|
+
export async function updateControl(cache, trust, args, running) {
|
|
7
|
+
const [action = 'status', ...extra] = args;
|
|
8
|
+
if (action === 'status' && !extra.length) return updateStatus(cache, running);
|
|
9
|
+
if (action === 'check' && !extra.length) return checkForUpdates(cache, trust, { force: true });
|
|
10
|
+
if (action === 'retry' && extra.length === 1 && hex(extra[0])) return retryQuarantine(cache, extra[0]);
|
|
11
|
+
if (action === 'auto' && extra.length === 1 && extra[0] === '--consent') return setUpdatePolicy(cache, { mode: 'auto', consent: true });
|
|
12
|
+
if (action === 'notify' && (!extra.length || (extra.length === 1 && extra[0] === '--release-pin'))) return setUpdatePolicy(cache, { mode: 'notify', releasePin: extra[0] === '--release-pin' });
|
|
13
|
+
if (action === 'pin' && extra.length === 1 && hex(extra[0])) {
|
|
14
|
+
const runtime = await readInstalled(cache, extra[0]);
|
|
15
|
+
return setUpdatePolicy(cache, { mode: 'pinned', pin: { version: runtime.productVersion, digest: runtime.digest } });
|
|
16
|
+
}
|
|
17
|
+
if (action === 'channel' && extra.length === 2 && ['stable', 'beta', 'rc'].includes(extra[0]) && extra[1] === '--confirm') {
|
|
18
|
+
const policy = await readUpdatePolicy(cache);
|
|
19
|
+
if (trust.status === 'published' && !trust.channels?.[extra[0]] && trust.channel !== extra[0]) throw Error('UPDATE_CHANNEL_UNAVAILABLE');
|
|
20
|
+
return setUpdatePolicy(cache, { mode: policy.mode, channel: extra[0], pin: policy.pin });
|
|
21
|
+
}
|
|
22
|
+
throw Error('INVALID_ARGUMENTS');
|
|
23
|
+
}
|