create-threadlens-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +25 -0
- package/src/args.js +80 -0
- package/src/commands.js +104 -0
- package/src/docker.js +80 -0
- package/src/health.js +39 -0
- package/src/index.js +37 -0
- package/src/install.js +96 -0
- package/src/manifest.js +94 -0
- package/src/output.js +52 -0
- package/src/ports.js +35 -0
- package/src/slug.js +13 -0
- package/src/templates.js +139 -0
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-threadlens-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One-command local-first ThreadLens self-host installer",
|
|
5
|
+
"license": "BUSL-1.1",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"create-threadlens-app": "src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "vitest run"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20.0.0"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"vitest": "^1.6.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const MANAGEMENT_COMMANDS = new Set(['start', 'stop', 'restart', 'status', 'logs', 'doctor', 'upgrade', 'reset']);
|
|
2
|
+
const SUPPORTED_PROFILES = new Set(['prod', 'dev']);
|
|
3
|
+
|
|
4
|
+
export function parseArgs(argv) {
|
|
5
|
+
const args = [...argv];
|
|
6
|
+
const first = args[0];
|
|
7
|
+
const command = MANAGEMENT_COMMANDS.has(first) ? args.shift() : 'install';
|
|
8
|
+
const options = {
|
|
9
|
+
command,
|
|
10
|
+
directory: command === 'install' ? 'threadlens' : undefined,
|
|
11
|
+
yes: false,
|
|
12
|
+
port: 4749,
|
|
13
|
+
portMode: 'fixed',
|
|
14
|
+
name: undefined,
|
|
15
|
+
profile: 'prod',
|
|
16
|
+
open: true,
|
|
17
|
+
verbose: false,
|
|
18
|
+
webPort: undefined,
|
|
19
|
+
apiPort: undefined,
|
|
20
|
+
resetMode: undefined,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
if (command === 'reset' && args[0] && !args[0].startsWith('-')) {
|
|
24
|
+
options.resetMode = args.shift();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
while (args.length > 0) {
|
|
28
|
+
const token = args.shift();
|
|
29
|
+
if (token === '--yes' || token === '-y') {
|
|
30
|
+
options.yes = true;
|
|
31
|
+
} else if (token === '--port') {
|
|
32
|
+
const value = requireValue(token, args.shift());
|
|
33
|
+
if (value === 'auto') {
|
|
34
|
+
options.port = 4749;
|
|
35
|
+
options.portMode = 'auto';
|
|
36
|
+
} else {
|
|
37
|
+
options.port = parsePort(value);
|
|
38
|
+
options.portMode = 'fixed';
|
|
39
|
+
}
|
|
40
|
+
} else if (token === '--name') {
|
|
41
|
+
options.name = requireValue(token, args.shift());
|
|
42
|
+
} else if (token === '--profile') {
|
|
43
|
+
const profile = requireValue(token, args.shift());
|
|
44
|
+
if (!SUPPORTED_PROFILES.has(profile)) throw new Error(`Unsupported profile: ${profile}`);
|
|
45
|
+
options.profile = profile;
|
|
46
|
+
} else if (token === '--web-port') {
|
|
47
|
+
options.webPort = parsePort(requireValue(token, args.shift()));
|
|
48
|
+
} else if (token === '--api-port') {
|
|
49
|
+
options.apiPort = parsePort(requireValue(token, args.shift()));
|
|
50
|
+
} else if (token === '--no-open') {
|
|
51
|
+
options.open = false;
|
|
52
|
+
} else if (token === '--verbose') {
|
|
53
|
+
options.verbose = true;
|
|
54
|
+
} else if (token.startsWith('-')) {
|
|
55
|
+
throw new Error(`Unknown option: ${token}`);
|
|
56
|
+
} else if (command === 'install') {
|
|
57
|
+
options.directory = token;
|
|
58
|
+
} else {
|
|
59
|
+
throw new Error(`Unexpected argument for ${command}: ${token}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (options.profile === 'dev') {
|
|
64
|
+
options.webPort = options.webPort ?? 4748;
|
|
65
|
+
options.apiPort = options.apiPort ?? options.port;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return options;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function requireValue(flag, value) {
|
|
72
|
+
if (!value || value.startsWith('-')) throw new Error(`Missing value for ${flag}`);
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parsePort(value) {
|
|
77
|
+
const port = Number(value);
|
|
78
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid port: ${value}`);
|
|
79
|
+
return port;
|
|
80
|
+
}
|
package/src/commands.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { checkDocker, defaultRun, logsCompose, resetDataVolume, startCompose, statusCompose, stopCompose } from './docker.js';
|
|
3
|
+
import { waitForReady as defaultWaitForReady } from './health.js';
|
|
4
|
+
import { readManifest, readState, updateState } from './manifest.js';
|
|
5
|
+
import { CliError, createLogger } from './output.js';
|
|
6
|
+
|
|
7
|
+
export async function runCommand(options, deps = {}) {
|
|
8
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
9
|
+
const logger = deps.logger ?? createLogger({ verbose: options.verbose });
|
|
10
|
+
const run = deps.run ?? defaultRun;
|
|
11
|
+
const waitForReady = deps.waitForReady ?? defaultWaitForReady;
|
|
12
|
+
const manifest = await readManifest(cwd);
|
|
13
|
+
|
|
14
|
+
if (options.command === 'start') {
|
|
15
|
+
await checkDocker({ run });
|
|
16
|
+
await startCompose(manifest, { run, stream: options.verbose });
|
|
17
|
+
await waitForReady({ apiUrl: `http://localhost:${manifest.ports.api}`, webUrl: `http://localhost:${manifest.ports.web}` });
|
|
18
|
+
await updateState(cwd, { status: 'running', lastStartedAt: new Date().toISOString() });
|
|
19
|
+
logger.success(`ThreadLens is running at http://localhost:${manifest.ports.app}`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (options.command === 'stop') {
|
|
24
|
+
await stopCompose(manifest, { run });
|
|
25
|
+
await updateState(cwd, { status: 'stopped', lastStoppedAt: new Date().toISOString() });
|
|
26
|
+
logger.success('ThreadLens stopped. Data volume was preserved.');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (options.command === 'restart') {
|
|
31
|
+
await stopCompose(manifest, { run });
|
|
32
|
+
await startCompose(manifest, { run, stream: options.verbose });
|
|
33
|
+
await waitForReady({ apiUrl: `http://localhost:${manifest.ports.api}`, webUrl: `http://localhost:${manifest.ports.web}` });
|
|
34
|
+
await updateState(cwd, { status: 'running', lastStartedAt: new Date().toISOString() });
|
|
35
|
+
logger.success(`ThreadLens restarted at http://localhost:${manifest.ports.app}`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (options.command === 'status') {
|
|
40
|
+
const result = await statusCompose(manifest, { run });
|
|
41
|
+
logger.info(result.stdout || 'No Compose status output.');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (options.command === 'logs') {
|
|
46
|
+
await logsCompose(manifest, { run });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (options.command === 'doctor') {
|
|
51
|
+
await doctor(cwd, manifest, { logger, run, waitForReady });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (options.command === 'upgrade') {
|
|
56
|
+
const state = await readState(cwd);
|
|
57
|
+
await updateState(cwd, {
|
|
58
|
+
status: state.status ?? 'unknown',
|
|
59
|
+
currentVersion: manifest.threadlensVersion,
|
|
60
|
+
previousVersion: state.previousVersion ?? null,
|
|
61
|
+
});
|
|
62
|
+
logger.success(`This instance is already on ThreadLens ${manifest.threadlensVersion}. Future versions will update compose.yml, restart, and re-run readiness checks.`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (options.command === 'reset') {
|
|
67
|
+
await reset(options, cwd, manifest, { logger, run });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new CliError('unknown_command', `Unknown command: ${options.command}`, 1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function doctor(cwd, manifest, { logger, run, waitForReady }) {
|
|
75
|
+
logger.step('ThreadLens doctor');
|
|
76
|
+
logger.info(`Directory: ${cwd}`);
|
|
77
|
+
logger.info(`Compose project: ${manifest.composeProject}`);
|
|
78
|
+
logger.info(`Data volume: ${manifest.volumeName}`);
|
|
79
|
+
logger.info(`App URL: http://localhost:${manifest.ports.app}`);
|
|
80
|
+
await checkDocker({ run });
|
|
81
|
+
logger.success('Docker and Compose are available');
|
|
82
|
+
|
|
83
|
+
const env = await readFile(`${cwd}/.env`, 'utf8').catch(() => '');
|
|
84
|
+
for (const key of ['ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'PARALLEL_API_KEY', 'BLUESKY_HANDLE', 'BLUESKY_APP_PASSWORD']) {
|
|
85
|
+
const match = env.match(new RegExp(`^${key}=(.*)$`, 'm'));
|
|
86
|
+
logger.info(`${key}: ${match && match[1] ? 'set' : 'empty'}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await waitForReady({ apiUrl: `http://localhost:${manifest.ports.api}`, webUrl: `http://localhost:${manifest.ports.web}`, retries: 1, delayMs: 1 }).catch((error) => logger.warn(error.message));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function reset(options, cwd, manifest, { logger, run }) {
|
|
93
|
+
const mode = options.resetMode ?? 'data';
|
|
94
|
+
if (mode !== 'data') {
|
|
95
|
+
throw new CliError('unsupported_reset_mode', `reset ${mode} is not implemented in the first version. Use reset data to delete only the SQLite Docker volume.`, 9);
|
|
96
|
+
}
|
|
97
|
+
if (!options.yes) {
|
|
98
|
+
throw new CliError('confirmation_required', `reset data deletes ${manifest.volumeName}. Re-run with --yes after confirming you have backed up any data you need.`, 10);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await resetDataVolume(manifest, { run });
|
|
102
|
+
await updateState(cwd, { status: 'reset', lastResetAt: new Date().toISOString() });
|
|
103
|
+
logger.success(`Deleted Docker volume ${manifest.volumeName}. .env and generated files were preserved.`);
|
|
104
|
+
}
|
package/src/docker.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { CliError, redactSecrets } from './output.js';
|
|
3
|
+
|
|
4
|
+
export async function defaultRun(command, args, { cwd = process.cwd(), stream = false } = {}) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const child = spawn(command, args, {
|
|
7
|
+
cwd,
|
|
8
|
+
stdio: stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
let stdout = '';
|
|
12
|
+
let stderr = '';
|
|
13
|
+
|
|
14
|
+
if (child.stdout) {
|
|
15
|
+
child.stdout.on('data', (data) => {
|
|
16
|
+
stdout += data;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (child.stderr) {
|
|
21
|
+
child.stderr.on('data', (data) => {
|
|
22
|
+
stderr += data;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
child.once('error', reject);
|
|
27
|
+
child.once('close', (code) => {
|
|
28
|
+
const result = { stdout: redactSecrets(stdout), stderr: redactSecrets(stderr), code };
|
|
29
|
+
if (code === 0) resolve(result);
|
|
30
|
+
else reject(Object.assign(new Error(result.stderr || `${command} exited with ${code}`), result));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function checkDocker({ run = defaultRun } = {}) {
|
|
36
|
+
try {
|
|
37
|
+
await run('docker', ['--version']);
|
|
38
|
+
} catch {
|
|
39
|
+
throw new CliError('missing_docker', 'Docker is required. Install Docker Desktop or Docker Engine, then re-run this command.', 4);
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
await run('docker', ['info']);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new CliError('docker_daemon_unavailable', 'Docker is installed but not running. Start Docker Desktop or Docker Engine, then re-run this command.', 5);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await run('docker', ['compose', 'version']);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new CliError('compose_unsupported', 'Docker Compose v2 is required. Update Docker Desktop or install the Docker Compose plugin.', 6);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function compose(manifest, args, { run = defaultRun, stream = false } = {}) {
|
|
54
|
+
return run(
|
|
55
|
+
'docker',
|
|
56
|
+
['compose', '-p', manifest.composeProject, '--env-file', '.env', '-f', manifest.composeFile, ...args],
|
|
57
|
+
{ cwd: manifest.appDir, stream }
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function startCompose(manifest, options = {}) {
|
|
62
|
+
return compose(manifest, ['up', '-d'], options);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function stopCompose(manifest, options = {}) {
|
|
66
|
+
return compose(manifest, ['down'], options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function statusCompose(manifest, options = {}) {
|
|
70
|
+
return compose(manifest, ['ps'], options);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function logsCompose(manifest, options = {}) {
|
|
74
|
+
return compose(manifest, ['logs', '--tail=100'], { ...options, stream: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function resetDataVolume(manifest, { run = defaultRun } = {}) {
|
|
78
|
+
await stopCompose(manifest, { run });
|
|
79
|
+
await run('docker', ['volume', 'rm', manifest.volumeName], { cwd: manifest.appDir, stream: false });
|
|
80
|
+
}
|
package/src/health.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CliError } from './output.js';
|
|
2
|
+
|
|
3
|
+
export async function waitForReady({ apiUrl, webUrl, fetch = globalThis.fetch, retries = 60, delayMs = 2000 }) {
|
|
4
|
+
let lastError = null;
|
|
5
|
+
|
|
6
|
+
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
|
7
|
+
try {
|
|
8
|
+
const health = await getJson(fetch, `${apiUrl}/api/health`);
|
|
9
|
+
const onboarding = await getJson(fetch, `${apiUrl}/api/onboarding/status`);
|
|
10
|
+
await getText(fetch, webUrl);
|
|
11
|
+
return { health, onboarding, webUrl };
|
|
12
|
+
} catch (error) {
|
|
13
|
+
lastError = error;
|
|
14
|
+
if (attempt < retries) await sleep(delayMs);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
throw new CliError(
|
|
19
|
+
'health_timeout',
|
|
20
|
+
`ThreadLens containers started, but readiness checks did not pass. Last error: ${lastError?.message ?? 'unknown error'}. Run npx create-threadlens-app@latest doctor or logs.`,
|
|
21
|
+
7
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function getJson(fetch, url) {
|
|
26
|
+
const response = await fetch(url);
|
|
27
|
+
if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`);
|
|
28
|
+
return response.json();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function getText(fetch, url) {
|
|
32
|
+
const response = await fetch(url);
|
|
33
|
+
if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`);
|
|
34
|
+
return response.text();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sleep(ms) {
|
|
38
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
39
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from './args.js';
|
|
3
|
+
import { runCommand as defaultRunCommand } from './commands.js';
|
|
4
|
+
import { install as defaultInstall } from './install.js';
|
|
5
|
+
import { createLogger } from './output.js';
|
|
6
|
+
|
|
7
|
+
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
8
|
+
const stdout = deps.stdout ?? console.log;
|
|
9
|
+
const stderr = deps.stderr ?? console.error;
|
|
10
|
+
|
|
11
|
+
if (argv.includes('--version') || argv.includes('-v')) {
|
|
12
|
+
const { default: pkg } = await import('../package.json', { with: { type: 'json' } });
|
|
13
|
+
stdout(pkg.version);
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const options = parseArgs(argv);
|
|
19
|
+
const logger = createLogger({ verbose: options.verbose, stdout, stderr });
|
|
20
|
+
if (options.command === 'install') {
|
|
21
|
+
await (deps.install ?? defaultInstall)(options, { logger });
|
|
22
|
+
} else {
|
|
23
|
+
await (deps.runCommand ?? defaultRunCommand)(options, { logger });
|
|
24
|
+
}
|
|
25
|
+
return 0;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
const exitCode = Number.isInteger(error.exitCode) ? error.exitCode : 1;
|
|
28
|
+
stderr(`✕ ${error.message}`);
|
|
29
|
+
if (argv.includes('--verbose') && error.stack) stderr(error.stack);
|
|
30
|
+
return exitCode;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
35
|
+
const code = await main();
|
|
36
|
+
process.exitCode = code;
|
|
37
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { mkdir, readdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, resolve } from 'node:path';
|
|
3
|
+
import { checkDocker, defaultRun, startCompose } from './docker.js';
|
|
4
|
+
import { waitForReady as defaultWaitForReady } from './health.js';
|
|
5
|
+
import { createManifest, DEFAULT_THREADLENS_VERSION, updateState, writeManifestBundle } from './manifest.js';
|
|
6
|
+
import { createLogger, CliError } from './output.js';
|
|
7
|
+
import { assertPortAvailable, findAvailablePort } from './ports.js';
|
|
8
|
+
import { renderCompose, renderEnv, renderGitignore, renderReadme } from './templates.js';
|
|
9
|
+
|
|
10
|
+
export async function install(options, deps = {}) {
|
|
11
|
+
const logger = deps.logger ?? createLogger({ verbose: options.verbose });
|
|
12
|
+
const run = deps.run ?? defaultRun;
|
|
13
|
+
const waitForReady = deps.waitForReady ?? defaultWaitForReady;
|
|
14
|
+
const appDir = resolve(options.directory);
|
|
15
|
+
const instanceName = options.name ?? basename(appDir);
|
|
16
|
+
|
|
17
|
+
logger.info('ThreadLens local self-host setup\n');
|
|
18
|
+
logger.info('This will create a local ThreadLens app directory, start ThreadLens with Docker, store data in an isolated Docker volume, and open the setup wizard.');
|
|
19
|
+
|
|
20
|
+
logger.step('Checking target directory');
|
|
21
|
+
await ensureInstallDirectory(appDir);
|
|
22
|
+
logger.success(`Directory ready: ${appDir}`);
|
|
23
|
+
|
|
24
|
+
logger.step('Checking Docker prerequisites');
|
|
25
|
+
await checkDocker({ run });
|
|
26
|
+
logger.success('Docker and Compose are ready');
|
|
27
|
+
|
|
28
|
+
logger.step('Checking local ports');
|
|
29
|
+
const appPort = options.portMode === 'auto' ? await findAvailablePort(options.port) : await assertPortAvailable(options.port);
|
|
30
|
+
const apiPort = options.profile === 'dev' ? (options.apiPort ?? appPort) : appPort;
|
|
31
|
+
const webPort = options.profile === 'dev' ? (options.webPort ?? 4748) : appPort;
|
|
32
|
+
if (options.profile === 'dev') {
|
|
33
|
+
await assertPortAvailable(apiPort);
|
|
34
|
+
await assertPortAvailable(webPort);
|
|
35
|
+
}
|
|
36
|
+
logger.success(`Using app URL http://localhost:${appPort}`);
|
|
37
|
+
|
|
38
|
+
const manifest = createManifest({
|
|
39
|
+
appDir,
|
|
40
|
+
instanceName,
|
|
41
|
+
profile: options.profile,
|
|
42
|
+
appPort,
|
|
43
|
+
webPort,
|
|
44
|
+
apiPort,
|
|
45
|
+
version: DEFAULT_THREADLENS_VERSION,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
logger.step('Writing generated ThreadLens files');
|
|
49
|
+
await mkdir(appDir, { recursive: true });
|
|
50
|
+
await writeFile(`${appDir}/.env`, renderEnv(manifest), { flag: 'wx', mode: 0o600 });
|
|
51
|
+
await writeFile(`${appDir}/compose.yml`, renderCompose(manifest), { flag: 'wx' });
|
|
52
|
+
await writeFile(`${appDir}/README.md`, renderReadme(manifest), { flag: 'wx' });
|
|
53
|
+
await writeFile(`${appDir}/.gitignore`, renderGitignore(), { flag: 'wx' });
|
|
54
|
+
await writeManifestBundle(appDir, manifest);
|
|
55
|
+
logger.success('Generated .env, compose.yml, README.md, .gitignore, and .threadlens metadata');
|
|
56
|
+
|
|
57
|
+
logger.step('Starting Docker Compose');
|
|
58
|
+
await startCompose(manifest, { run, stream: options.verbose });
|
|
59
|
+
await updateState(appDir, { status: 'starting', lastStartedAt: new Date().toISOString() });
|
|
60
|
+
logger.success('Docker stack started');
|
|
61
|
+
|
|
62
|
+
logger.step('Waiting for ThreadLens readiness');
|
|
63
|
+
await waitForReady({ apiUrl: `http://localhost:${apiPort}`, webUrl: `http://localhost:${webPort}` });
|
|
64
|
+
await updateState(appDir, { status: 'running', lastReadyAt: new Date().toISOString() });
|
|
65
|
+
logger.success('API, onboarding status, and web app are reachable');
|
|
66
|
+
|
|
67
|
+
const appUrl = `http://localhost:${appPort}`;
|
|
68
|
+
logger.info('\nThreadLens is running.\n');
|
|
69
|
+
logger.info(`Open: ${appUrl}\n`);
|
|
70
|
+
logger.info('Next: complete the setup wizard, choose one AI provider path, create one focused research project, add one narrow Reddit query, and run your first scout manually.');
|
|
71
|
+
logger.info('\nManagement commands: start, stop, restart, status, logs, doctor, upgrade, reset data');
|
|
72
|
+
|
|
73
|
+
if (options.open && process.stdout.isTTY && !process.env.CI) {
|
|
74
|
+
logger.info(`Open your browser to ${appUrl}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { appDir, manifest, appUrl };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function ensureInstallDirectory(appDir) {
|
|
81
|
+
try {
|
|
82
|
+
const entries = await readdir(appDir);
|
|
83
|
+
const allowedExisting = entries.length === 0 || entries.includes('.threadlens');
|
|
84
|
+
if (!allowedExisting) {
|
|
85
|
+
throw new CliError(
|
|
86
|
+
'directory_conflict',
|
|
87
|
+
`Refusing to overwrite non-empty directory ${appDir}. Choose another directory or run from an existing generated ThreadLens app.`,
|
|
88
|
+
8
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error.code === 'ENOENT') return;
|
|
93
|
+
if (error instanceof CliError) throw error;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/manifest.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { CliError } from './output.js';
|
|
4
|
+
import { composeProjectName, safeSlug } from './slug.js';
|
|
5
|
+
|
|
6
|
+
export const MANIFEST_VERSION = 1;
|
|
7
|
+
export const DEFAULT_THREADLENS_VERSION = '0.1.0';
|
|
8
|
+
|
|
9
|
+
export function createManifest({ appDir, instanceName, profile, appPort, webPort, apiPort, version = DEFAULT_THREADLENS_VERSION }) {
|
|
10
|
+
const slug = safeSlug(instanceName);
|
|
11
|
+
const composeProject = composeProjectName(slug);
|
|
12
|
+
return {
|
|
13
|
+
manifestVersion: MANIFEST_VERSION,
|
|
14
|
+
createdBy: 'create-threadlens-app',
|
|
15
|
+
instanceName,
|
|
16
|
+
slug,
|
|
17
|
+
appDir: resolve(appDir),
|
|
18
|
+
profile,
|
|
19
|
+
threadlensVersion: version,
|
|
20
|
+
composeFile: 'compose.yml',
|
|
21
|
+
envFile: '.env',
|
|
22
|
+
composeProject,
|
|
23
|
+
volumeName: `${composeProject}_sqlite_data`,
|
|
24
|
+
ports: {
|
|
25
|
+
app: appPort,
|
|
26
|
+
web: webPort,
|
|
27
|
+
api: apiPort,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function writeManifestBundle(appDir, manifest) {
|
|
33
|
+
const metaDir = join(appDir, '.threadlens');
|
|
34
|
+
await mkdir(metaDir, { recursive: true });
|
|
35
|
+
await writeJson(join(metaDir, 'manifest.json'), manifest);
|
|
36
|
+
await writeJson(join(metaDir, 'state.json'), {
|
|
37
|
+
status: 'created',
|
|
38
|
+
currentVersion: manifest.threadlensVersion,
|
|
39
|
+
previousVersion: null,
|
|
40
|
+
lastStartedAt: null,
|
|
41
|
+
lastStoppedAt: null,
|
|
42
|
+
lastUpgradeAt: null,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readManifest(appDir = process.cwd()) {
|
|
47
|
+
const manifestPath = join(appDir, '.threadlens', 'manifest.json');
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = await readFile(manifestPath, 'utf8');
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error.code === 'ENOENT') {
|
|
53
|
+
throw new CliError('not_threadlens_app', `This directory is not a ThreadLens app directory. Run this command from a directory containing .threadlens/manifest.json.`, 3);
|
|
54
|
+
}
|
|
55
|
+
throw new CliError('manifest_read_error', `Failed to read manifest: ${error.message}`, 3);
|
|
56
|
+
}
|
|
57
|
+
let manifest;
|
|
58
|
+
try {
|
|
59
|
+
manifest = JSON.parse(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new CliError('manifest_corrupt', `The manifest file at ${manifestPath} is not valid JSON. The installation may be corrupted.`, 3);
|
|
62
|
+
}
|
|
63
|
+
if (manifest.manifestVersion !== MANIFEST_VERSION || manifest.createdBy !== 'create-threadlens-app') {
|
|
64
|
+
throw new CliError('manifest_incompatible', `The manifest at ${manifestPath} is not compatible with this version of create-threadlens-app.`, 3);
|
|
65
|
+
}
|
|
66
|
+
return manifest;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function readState(appDir = process.cwd()) {
|
|
70
|
+
const statePath = join(appDir, '.threadlens', 'state.json');
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
raw = await readFile(statePath, 'utf8');
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.code === 'ENOENT') {
|
|
76
|
+
return { status: 'unknown', currentVersion: null, previousVersion: null };
|
|
77
|
+
}
|
|
78
|
+
throw new CliError('state_read_error', `Failed to read state file: ${error.message}`, 3);
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(raw);
|
|
82
|
+
} catch {
|
|
83
|
+
throw new CliError('state_corrupt', `The state file at ${statePath} is not valid JSON. The installation may be corrupted.`, 3);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function updateState(appDir, patch) {
|
|
88
|
+
const current = await readState(appDir);
|
|
89
|
+
await writeJson(join(appDir, '.threadlens', 'state.json'), { ...current, ...patch });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function writeJson(path, value) {
|
|
93
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
94
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const SECRET_NAMES = ['ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'PARALLEL_API_KEY', 'BLUESKY_APP_PASSWORD', 'SCOUT_AI_BRIDGE_TOKEN'];
|
|
2
|
+
|
|
3
|
+
export class CliError extends Error {
|
|
4
|
+
constructor(code, message, exitCode = 1) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'CliError';
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.exitCode = exitCode;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function redactSecrets(value) {
|
|
13
|
+
let text = String(value ?? '');
|
|
14
|
+
for (const name of SECRET_NAMES) {
|
|
15
|
+
// bare: NAME=value or NAME = value (env file style)
|
|
16
|
+
text = text.replace(new RegExp(`(${name}\\s*=\\s*)([^\\n\\r]+)`, 'g'), `$1[redacted]`);
|
|
17
|
+
// YAML/colon: NAME: value
|
|
18
|
+
text = text.replace(new RegExp(`(${name}\\s*:\\s*)([^\\n\\r,}\\]]+)`, 'g'), (full, prefix, rawValue) => {
|
|
19
|
+
const normalized = String(rawValue).trim().toLowerCase();
|
|
20
|
+
if (normalized === 'set' || normalized === 'empty' || normalized === '[redacted]') {
|
|
21
|
+
return full;
|
|
22
|
+
}
|
|
23
|
+
return `${prefix}[redacted]`;
|
|
24
|
+
});
|
|
25
|
+
// JSON/quoted value: "NAME": "value" or "NAME":"value"
|
|
26
|
+
text = text.replace(new RegExp(`("${name}"\\s*:\\s*)"([^"]*)"`, 'g'), `"${name}": "[redacted]"`);
|
|
27
|
+
// standalone quoted value in any context: NAME="value" or NAME='value'
|
|
28
|
+
text = text.replace(new RegExp(`(${name}\\s*=\\s*)(['"])([^'"]+)\\2`, 'g'), `$1[redacted]`);
|
|
29
|
+
}
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createLogger({ verbose = false, stdout = console.log, stderr = console.error } = {}) {
|
|
34
|
+
const lines = [];
|
|
35
|
+
function write(stream, line) {
|
|
36
|
+
const redacted = redactSecrets(line);
|
|
37
|
+
lines.push(redacted);
|
|
38
|
+
stream(redacted);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
lines,
|
|
42
|
+
verbose,
|
|
43
|
+
info: (message) => write(stdout, message),
|
|
44
|
+
step: (message) => write(stdout, `==> ${message}`),
|
|
45
|
+
success: (message) => write(stdout, `✓ ${message}`),
|
|
46
|
+
warn: (message) => write(stderr, `! ${message}`),
|
|
47
|
+
error: (message) => write(stderr, `✕ ${message}`),
|
|
48
|
+
debug: (message) => {
|
|
49
|
+
if (verbose) write(stderr, `[debug] ${message}`);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/ports.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { CliError } from './output.js';
|
|
3
|
+
|
|
4
|
+
function assertValidPort(port, label = 'port') {
|
|
5
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
6
|
+
throw new CliError('invalid_port', `${label} must be an integer between 1 and 65535 (got ${port}).`, 1);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function assertPortAvailable(port) {
|
|
11
|
+
assertValidPort(port);
|
|
12
|
+
const available = await canListen(port);
|
|
13
|
+
if (!available) {
|
|
14
|
+
throw new CliError('port_conflict', `Port ${port} is already in use. Re-run with --port auto or choose another port.`, 2);
|
|
15
|
+
}
|
|
16
|
+
return port;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function findAvailablePort(startPort) {
|
|
20
|
+
assertValidPort(startPort, 'startPort');
|
|
21
|
+
for (let port = startPort; port <= 65535; port += 1) {
|
|
22
|
+
if (await canListen(port)) return port;
|
|
23
|
+
}
|
|
24
|
+
throw new CliError('port_conflict', `No available port found at or above ${startPort}.`, 2);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function canListen(port) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const server = net.createServer();
|
|
30
|
+
server.once('error', () => resolve(false));
|
|
31
|
+
server.listen(port, '127.0.0.1', () => {
|
|
32
|
+
server.close(() => resolve(true));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
package/src/slug.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function safeSlug(value) {
|
|
2
|
+
const slug = String(value ?? '')
|
|
3
|
+
.toLowerCase()
|
|
4
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
5
|
+
.replace(/^-+|-+$/g, '')
|
|
6
|
+
.slice(0, 48);
|
|
7
|
+
return slug || 'threadlens';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function composeProjectName(value) {
|
|
11
|
+
const slug = safeSlug(value);
|
|
12
|
+
return slug.startsWith('threadlens-') ? slug : `threadlens-${slug}`;
|
|
13
|
+
}
|
package/src/templates.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export function renderEnv(manifest) {
|
|
2
|
+
return `# ThreadLens local self-host configuration
|
|
3
|
+
# Provider keys are optional during install. You can add them here or in the setup wizard.
|
|
4
|
+
THREADLENS_VERSION=${manifest.threadlensVersion}
|
|
5
|
+
THREADLENS_APP_PORT=${manifest.ports.app}
|
|
6
|
+
THREADLENS_WEB_PORT=${manifest.ports.web}
|
|
7
|
+
THREADLENS_API_PORT=${manifest.ports.api}
|
|
8
|
+
PORT=4749
|
|
9
|
+
SCOUT_DB_PATH=/data/scout.db
|
|
10
|
+
SCOUT_FRONTEND_DIST=/app/web/dist
|
|
11
|
+
SCOUT_ONBOARDING_MODE=docker
|
|
12
|
+
ANTHROPIC_API_KEY=
|
|
13
|
+
GEMINI_API_KEY=
|
|
14
|
+
PARALLEL_API_KEY=
|
|
15
|
+
BLUESKY_HANDLE=
|
|
16
|
+
BLUESKY_APP_PASSWORD=
|
|
17
|
+
`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderCompose(manifest) {
|
|
21
|
+
if (manifest.profile === 'dev') return renderDevCompose(manifest);
|
|
22
|
+
return `name: ${manifest.composeProject}
|
|
23
|
+
|
|
24
|
+
services:
|
|
25
|
+
threadlens:
|
|
26
|
+
image: ghcr.io/threadlenshq/threadlens:${'${THREADLENS_VERSION}'}
|
|
27
|
+
container_name: ${manifest.composeProject}-app
|
|
28
|
+
restart: unless-stopped
|
|
29
|
+
env_file:
|
|
30
|
+
- .env
|
|
31
|
+
environment:
|
|
32
|
+
PORT: "4749"
|
|
33
|
+
SCOUT_DB_PATH: /data/scout.db
|
|
34
|
+
SCOUT_FRONTEND_DIST: /app/web/dist
|
|
35
|
+
SCOUT_ONBOARDING_MODE: docker
|
|
36
|
+
ports:
|
|
37
|
+
- "${'${THREADLENS_APP_PORT}'}:4749"
|
|
38
|
+
volumes:
|
|
39
|
+
- sqlite_data:/data
|
|
40
|
+
- type: bind
|
|
41
|
+
source: ./.env
|
|
42
|
+
target: /data/.env
|
|
43
|
+
labels:
|
|
44
|
+
threadlens.instance: ${manifest.slug}
|
|
45
|
+
threadlens.managed-by: create-threadlens-app
|
|
46
|
+
healthcheck:
|
|
47
|
+
test: ["CMD", "wget", "-qO-", "http://localhost:4749/api/health"]
|
|
48
|
+
interval: 10s
|
|
49
|
+
timeout: 5s
|
|
50
|
+
retries: 12
|
|
51
|
+
|
|
52
|
+
volumes:
|
|
53
|
+
sqlite_data:
|
|
54
|
+
name: ${manifest.volumeName}
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderDevCompose(manifest) {
|
|
59
|
+
return `name: ${manifest.composeProject}
|
|
60
|
+
|
|
61
|
+
services:
|
|
62
|
+
api:
|
|
63
|
+
image: ghcr.io/threadlenshq/threadlens-api-dev:${'${THREADLENS_VERSION}'}
|
|
64
|
+
container_name: ${manifest.composeProject}-api
|
|
65
|
+
env_file:
|
|
66
|
+
- .env
|
|
67
|
+
environment:
|
|
68
|
+
PORT: "4749"
|
|
69
|
+
SCOUT_DB_PATH: /data/scout.db
|
|
70
|
+
SCOUT_ONBOARDING_MODE: docker
|
|
71
|
+
ports:
|
|
72
|
+
- "${'${THREADLENS_API_PORT}'}:4749"
|
|
73
|
+
volumes:
|
|
74
|
+
- sqlite_data:/data
|
|
75
|
+
- type: bind
|
|
76
|
+
source: ./.env
|
|
77
|
+
target: /data/.env
|
|
78
|
+
labels:
|
|
79
|
+
threadlens.instance: ${manifest.slug}
|
|
80
|
+
threadlens.managed-by: create-threadlens-app
|
|
81
|
+
web:
|
|
82
|
+
image: ghcr.io/threadlenshq/threadlens-web-dev:${'${THREADLENS_VERSION}'}
|
|
83
|
+
container_name: ${manifest.composeProject}-web
|
|
84
|
+
environment:
|
|
85
|
+
VITE_API_PROXY_TARGET: http://api:4749
|
|
86
|
+
ports:
|
|
87
|
+
- "${'${THREADLENS_WEB_PORT}'}:4748"
|
|
88
|
+
depends_on:
|
|
89
|
+
- api
|
|
90
|
+
labels:
|
|
91
|
+
threadlens.instance: ${manifest.slug}
|
|
92
|
+
threadlens.managed-by: create-threadlens-app
|
|
93
|
+
|
|
94
|
+
volumes:
|
|
95
|
+
sqlite_data:
|
|
96
|
+
name: ${manifest.volumeName}
|
|
97
|
+
`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function renderReadme(manifest) {
|
|
101
|
+
const appUrl = `http://localhost:${manifest.ports.app}`;
|
|
102
|
+
return `# ${manifest.instanceName}
|
|
103
|
+
|
|
104
|
+
This directory was generated by \`create-threadlens-app\`. ThreadLens runs locally with Docker Compose and stores SQLite data in the Docker volume \`${manifest.volumeName}\`.
|
|
105
|
+
|
|
106
|
+
Open: ${appUrl}
|
|
107
|
+
|
|
108
|
+
## Commands
|
|
109
|
+
|
|
110
|
+
\`\`\`bash
|
|
111
|
+
npx create-threadlens-app@latest start
|
|
112
|
+
npx create-threadlens-app@latest stop
|
|
113
|
+
npx create-threadlens-app@latest restart
|
|
114
|
+
npx create-threadlens-app@latest status
|
|
115
|
+
npx create-threadlens-app@latest logs
|
|
116
|
+
npx create-threadlens-app@latest doctor
|
|
117
|
+
npx create-threadlens-app@latest upgrade
|
|
118
|
+
npx create-threadlens-app@latest reset data
|
|
119
|
+
\`\`\`
|
|
120
|
+
|
|
121
|
+
## Files
|
|
122
|
+
|
|
123
|
+
- \`.env\` contains local runtime settings and provider keys. Do not commit it.
|
|
124
|
+
- \`compose.yml\` is the exact Docker Compose file used by this instance.
|
|
125
|
+
- \`.threadlens/manifest.json\` records ports, Compose project names, and volume names.
|
|
126
|
+
|
|
127
|
+
## First run
|
|
128
|
+
|
|
129
|
+
Open ${appUrl}, complete the setup wizard, choose one AI provider path, create one focused research project, add one narrow Reddit query, and run your first scout manually.
|
|
130
|
+
|
|
131
|
+
## Reset warning
|
|
132
|
+
|
|
133
|
+
\`npx create-threadlens-app@latest reset data\` deletes the Docker SQLite volume for this instance. It does not delete \`.env\` or this app directory.
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function renderGitignore() {
|
|
138
|
+
return `.env\n.threadlens/diagnostics/\n`;
|
|
139
|
+
}
|