offwatch 0.5.8 → 0.5.10
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/bin/offwatch.js +7 -6
- package/package.json +4 -3
- package/src/__tests__/agent-jwt-env.test.ts +79 -0
- package/src/__tests__/allowed-hostname.test.ts +80 -0
- package/src/__tests__/auth-command-registration.test.ts +16 -0
- package/src/__tests__/board-auth.test.ts +53 -0
- package/src/__tests__/common.test.ts +98 -0
- package/src/__tests__/company-delete.test.ts +95 -0
- package/src/__tests__/company-import-export-e2e.test.ts +502 -0
- package/src/__tests__/company-import-url.test.ts +74 -0
- package/src/__tests__/company-import-zip.test.ts +44 -0
- package/src/__tests__/company.test.ts +599 -0
- package/src/__tests__/context.test.ts +70 -0
- package/src/__tests__/data-dir.test.ts +79 -0
- package/src/__tests__/doctor.test.ts +102 -0
- package/src/__tests__/feedback.test.ts +177 -0
- package/src/__tests__/helpers/embedded-postgres.ts +6 -0
- package/src/__tests__/helpers/zip.ts +87 -0
- package/src/__tests__/home-paths.test.ts +44 -0
- package/src/__tests__/http.test.ts +106 -0
- package/src/__tests__/network-bind.test.ts +62 -0
- package/src/__tests__/onboard.test.ts +166 -0
- package/src/__tests__/routines.test.ts +249 -0
- package/src/__tests__/telemetry.test.ts +117 -0
- package/src/__tests__/worktree-merge-history.test.ts +492 -0
- package/src/__tests__/worktree.test.ts +982 -0
- package/src/adapters/http/format-event.ts +4 -0
- package/src/adapters/http/index.ts +7 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/process/format-event.ts +4 -0
- package/src/adapters/process/index.ts +7 -0
- package/src/adapters/registry.ts +63 -0
- package/src/checks/agent-jwt-secret-check.ts +40 -0
- package/src/checks/config-check.ts +33 -0
- package/src/checks/database-check.ts +59 -0
- package/src/checks/deployment-auth-check.ts +88 -0
- package/src/checks/index.ts +18 -0
- package/src/checks/llm-check.ts +82 -0
- package/src/checks/log-check.ts +30 -0
- package/src/checks/path-resolver.ts +1 -0
- package/src/checks/port-check.ts +24 -0
- package/src/checks/secrets-check.ts +146 -0
- package/src/checks/storage-check.ts +51 -0
- package/src/client/board-auth.ts +282 -0
- package/src/client/command-label.ts +4 -0
- package/src/client/context.ts +175 -0
- package/src/client/http.ts +255 -0
- package/src/commands/allowed-hostname.ts +40 -0
- package/src/commands/auth-bootstrap-ceo.ts +138 -0
- package/src/commands/client/activity.ts +71 -0
- package/src/commands/client/agent.ts +315 -0
- package/src/commands/client/approval.ts +259 -0
- package/src/commands/client/auth.ts +113 -0
- package/src/commands/client/common.ts +221 -0
- package/src/commands/client/company.ts +1578 -0
- package/src/commands/client/context.ts +125 -0
- package/src/commands/client/dashboard.ts +34 -0
- package/src/commands/client/feedback.ts +645 -0
- package/src/commands/client/issue.ts +411 -0
- package/src/commands/client/plugin.ts +374 -0
- package/src/commands/client/zip.ts +129 -0
- package/src/commands/configure.ts +201 -0
- package/src/commands/db-backup.ts +102 -0
- package/src/commands/doctor.ts +203 -0
- package/src/commands/env.ts +411 -0
- package/src/commands/heartbeat-run.ts +344 -0
- package/src/commands/onboard.ts +692 -0
- package/src/commands/routines.ts +352 -0
- package/src/commands/run.ts +216 -0
- package/src/commands/worktree-lib.ts +279 -0
- package/src/commands/worktree-merge-history-lib.ts +764 -0
- package/src/commands/worktree.ts +2876 -0
- package/src/config/data-dir.ts +48 -0
- package/src/config/env.ts +125 -0
- package/src/config/home.ts +80 -0
- package/src/config/hostnames.ts +26 -0
- package/src/config/schema.ts +30 -0
- package/src/config/secrets-key.ts +48 -0
- package/src/config/server-bind.ts +183 -0
- package/src/config/store.ts +120 -0
- package/src/index.ts +182 -0
- package/src/prompts/database.ts +157 -0
- package/src/prompts/llm.ts +43 -0
- package/src/prompts/logging.ts +37 -0
- package/src/prompts/secrets.ts +99 -0
- package/src/prompts/server.ts +221 -0
- package/src/prompts/storage.ts +146 -0
- package/src/telemetry.ts +49 -0
- package/src/utils/banner.ts +24 -0
- package/src/utils/net.ts +18 -0
- package/src/utils/path-resolver.ts +25 -0
- package/src/version.ts +10 -0
- package/lib/downloader.js +0 -112
- package/postinstall.js +0 -23
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { isLoopbackHost, type BindMode } from "@paperclipai/shared";
|
|
3
|
+
import type { AuthConfig, ServerConfig } from "../config/schema.js";
|
|
4
|
+
import { parseHostnameCsv } from "../config/hostnames.js";
|
|
5
|
+
import { buildCustomServerConfig, buildPresetServerConfig, inferConfiguredBind } from "../config/server-bind.js";
|
|
6
|
+
|
|
7
|
+
const TAILNET_BIND_WARNING =
|
|
8
|
+
"No Tailscale address was detected during setup. The saved config will stay on loopback until Tailscale is available or PAPERCLIP_TAILNET_BIND_HOST is set.";
|
|
9
|
+
|
|
10
|
+
function cancelled(): never {
|
|
11
|
+
p.cancel("Setup cancelled.");
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function promptServer(opts?: {
|
|
16
|
+
currentServer?: Partial<ServerConfig>;
|
|
17
|
+
currentAuth?: Partial<AuthConfig>;
|
|
18
|
+
}): Promise<{ server: ServerConfig; auth: AuthConfig }> {
|
|
19
|
+
const currentServer = opts?.currentServer;
|
|
20
|
+
const currentAuth = opts?.currentAuth;
|
|
21
|
+
const currentBind = inferConfiguredBind(currentServer);
|
|
22
|
+
|
|
23
|
+
const bindSelection = await p.select({
|
|
24
|
+
message: "Reachability",
|
|
25
|
+
options: [
|
|
26
|
+
{
|
|
27
|
+
value: "loopback" as const,
|
|
28
|
+
label: "Trusted local",
|
|
29
|
+
hint: "Recommended for first run: localhost only, no login friction",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
value: "lan" as const,
|
|
33
|
+
label: "Private network",
|
|
34
|
+
hint: "Broad private bind for LAN, VPN, or legacy --tailscale-auth style access",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
value: "tailnet" as const,
|
|
38
|
+
label: "Tailnet",
|
|
39
|
+
hint: "Private authenticated access using the machine's detected Tailscale address",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
value: "custom" as const,
|
|
43
|
+
label: "Custom",
|
|
44
|
+
hint: "Choose exact auth mode, exposure, and host manually",
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
initialValue: currentBind,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (p.isCancel(bindSelection)) cancelled();
|
|
51
|
+
const bind = bindSelection as BindMode;
|
|
52
|
+
|
|
53
|
+
const portStr = await p.text({
|
|
54
|
+
message: "Server port",
|
|
55
|
+
defaultValue: String(currentServer?.port ?? 3100),
|
|
56
|
+
placeholder: "3100",
|
|
57
|
+
validate: (val) => {
|
|
58
|
+
const n = Number(val);
|
|
59
|
+
if (isNaN(n) || n < 1 || n > 65535 || !Number.isInteger(n)) {
|
|
60
|
+
return "Must be an integer between 1 and 65535";
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (p.isCancel(portStr)) cancelled();
|
|
66
|
+
const port = Number(portStr) || 3100;
|
|
67
|
+
const serveUi = currentServer?.serveUi ?? true;
|
|
68
|
+
|
|
69
|
+
if (bind === "loopback") {
|
|
70
|
+
return buildPresetServerConfig("loopback", {
|
|
71
|
+
port,
|
|
72
|
+
allowedHostnames: [],
|
|
73
|
+
serveUi,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (bind === "lan" || bind === "tailnet") {
|
|
78
|
+
const allowedHostnamesInput = await p.text({
|
|
79
|
+
message: "Allowed private hostnames (comma-separated, optional)",
|
|
80
|
+
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
|
|
81
|
+
placeholder:
|
|
82
|
+
bind === "tailnet"
|
|
83
|
+
? "your-machine.tailnet.ts.net"
|
|
84
|
+
: "dotta-macbook-pro, host.docker.internal",
|
|
85
|
+
validate: (val) => {
|
|
86
|
+
try {
|
|
87
|
+
parseHostnameCsv(val);
|
|
88
|
+
return;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return err instanceof Error ? err.message : "Invalid hostname list";
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (p.isCancel(allowedHostnamesInput)) cancelled();
|
|
96
|
+
|
|
97
|
+
const preset = buildPresetServerConfig(bind, {
|
|
98
|
+
port,
|
|
99
|
+
allowedHostnames: parseHostnameCsv(allowedHostnamesInput),
|
|
100
|
+
serveUi,
|
|
101
|
+
});
|
|
102
|
+
if (bind === "tailnet" && isLoopbackHost(preset.server.host)) {
|
|
103
|
+
p.log.warn(TAILNET_BIND_WARNING);
|
|
104
|
+
}
|
|
105
|
+
return preset;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const deploymentModeSelection = await p.select({
|
|
109
|
+
message: "Auth mode",
|
|
110
|
+
options: [
|
|
111
|
+
{
|
|
112
|
+
value: "local_trusted",
|
|
113
|
+
label: "Local trusted",
|
|
114
|
+
hint: "No login required; only safe with loopback-only or similarly trusted access",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
value: "authenticated",
|
|
118
|
+
label: "Authenticated",
|
|
119
|
+
hint: "Login required; supports both private-network and public deployments",
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
initialValue: currentServer?.deploymentMode ?? "authenticated",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (p.isCancel(deploymentModeSelection)) cancelled();
|
|
126
|
+
const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"];
|
|
127
|
+
|
|
128
|
+
let exposure: ServerConfig["exposure"] = "private";
|
|
129
|
+
if (deploymentMode === "authenticated") {
|
|
130
|
+
const exposureSelection = await p.select({
|
|
131
|
+
message: "Exposure profile",
|
|
132
|
+
options: [
|
|
133
|
+
{
|
|
134
|
+
value: "private",
|
|
135
|
+
label: "Private network",
|
|
136
|
+
hint: "Private access only, with automatic URL handling",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
value: "public",
|
|
140
|
+
label: "Public internet",
|
|
141
|
+
hint: "Internet-facing deployment with explicit public URL requirements",
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
initialValue: currentServer?.exposure ?? "private",
|
|
145
|
+
});
|
|
146
|
+
if (p.isCancel(exposureSelection)) cancelled();
|
|
147
|
+
exposure = exposureSelection as ServerConfig["exposure"];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const defaultHost =
|
|
151
|
+
currentServer?.customBindHost ??
|
|
152
|
+
currentServer?.host ??
|
|
153
|
+
(deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0");
|
|
154
|
+
const host = await p.text({
|
|
155
|
+
message: "Bind host",
|
|
156
|
+
defaultValue: defaultHost,
|
|
157
|
+
placeholder: defaultHost,
|
|
158
|
+
validate: (val) => {
|
|
159
|
+
if (!val.trim()) return "Host is required";
|
|
160
|
+
if (deploymentMode === "local_trusted" && !isLoopbackHost(val.trim())) {
|
|
161
|
+
return "Local trusted mode requires a loopback host such as 127.0.0.1";
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (p.isCancel(host)) cancelled();
|
|
167
|
+
|
|
168
|
+
let allowedHostnames: string[] = [];
|
|
169
|
+
if (deploymentMode === "authenticated" && exposure === "private") {
|
|
170
|
+
const allowedHostnamesInput = await p.text({
|
|
171
|
+
message: "Allowed private hostnames (comma-separated, optional)",
|
|
172
|
+
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
|
|
173
|
+
placeholder: "dotta-macbook-pro, your-host.tailnet.ts.net",
|
|
174
|
+
validate: (val) => {
|
|
175
|
+
try {
|
|
176
|
+
parseHostnameCsv(val);
|
|
177
|
+
return;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return err instanceof Error ? err.message : "Invalid hostname list";
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (p.isCancel(allowedHostnamesInput)) cancelled();
|
|
185
|
+
allowedHostnames = parseHostnameCsv(allowedHostnamesInput);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let publicBaseUrl: string | undefined;
|
|
189
|
+
if (deploymentMode === "authenticated" && exposure === "public") {
|
|
190
|
+
const urlInput = await p.text({
|
|
191
|
+
message: "Public base URL",
|
|
192
|
+
defaultValue: currentAuth?.publicBaseUrl ?? "",
|
|
193
|
+
placeholder: "https://paperclip.example.com",
|
|
194
|
+
validate: (val) => {
|
|
195
|
+
const candidate = val.trim();
|
|
196
|
+
if (!candidate) return "Public base URL is required for public exposure";
|
|
197
|
+
try {
|
|
198
|
+
const url = new URL(candidate);
|
|
199
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
200
|
+
return "URL must start with http:// or https://";
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
} catch {
|
|
204
|
+
return "Enter a valid URL";
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
if (p.isCancel(urlInput)) cancelled();
|
|
209
|
+
publicBaseUrl = urlInput.trim().replace(/\/+$/, "");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return buildCustomServerConfig({
|
|
213
|
+
deploymentMode,
|
|
214
|
+
exposure,
|
|
215
|
+
host: host.trim(),
|
|
216
|
+
port,
|
|
217
|
+
allowedHostnames,
|
|
218
|
+
serveUi,
|
|
219
|
+
publicBaseUrl,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import type { StorageConfig } from "../config/schema.js";
|
|
3
|
+
import { resolveDefaultStorageDir, resolvePaperclipInstanceId } from "../config/home.js";
|
|
4
|
+
|
|
5
|
+
function defaultStorageBaseDir(): string {
|
|
6
|
+
return resolveDefaultStorageDir(resolvePaperclipInstanceId());
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function defaultStorageConfig(): StorageConfig {
|
|
10
|
+
return {
|
|
11
|
+
provider: "local_disk",
|
|
12
|
+
localDisk: {
|
|
13
|
+
baseDir: defaultStorageBaseDir(),
|
|
14
|
+
},
|
|
15
|
+
s3: {
|
|
16
|
+
bucket: "paperclip",
|
|
17
|
+
region: "us-east-1",
|
|
18
|
+
endpoint: undefined,
|
|
19
|
+
prefix: "",
|
|
20
|
+
forcePathStyle: false,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function promptStorage(current?: StorageConfig): Promise<StorageConfig> {
|
|
26
|
+
const base = current ?? defaultStorageConfig();
|
|
27
|
+
|
|
28
|
+
const provider = await p.select({
|
|
29
|
+
message: "Storage provider",
|
|
30
|
+
options: [
|
|
31
|
+
{
|
|
32
|
+
value: "local_disk" as const,
|
|
33
|
+
label: "Local disk (recommended)",
|
|
34
|
+
hint: "best for single-user local deployments",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
value: "s3" as const,
|
|
38
|
+
label: "S3 compatible",
|
|
39
|
+
hint: "for cloud/object storage backends",
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
initialValue: base.provider,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (p.isCancel(provider)) {
|
|
46
|
+
p.cancel("Setup cancelled.");
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (provider === "local_disk") {
|
|
51
|
+
const baseDir = await p.text({
|
|
52
|
+
message: "Local storage base directory",
|
|
53
|
+
defaultValue: base.localDisk.baseDir || defaultStorageBaseDir(),
|
|
54
|
+
placeholder: defaultStorageBaseDir(),
|
|
55
|
+
validate: (value) => {
|
|
56
|
+
if (!value || value.trim().length === 0) return "Storage base directory is required";
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (p.isCancel(baseDir)) {
|
|
61
|
+
p.cancel("Setup cancelled.");
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
provider: "local_disk",
|
|
67
|
+
localDisk: {
|
|
68
|
+
baseDir: baseDir.trim(),
|
|
69
|
+
},
|
|
70
|
+
s3: base.s3,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const bucket = await p.text({
|
|
75
|
+
message: "S3 bucket",
|
|
76
|
+
defaultValue: base.s3.bucket || "paperclip",
|
|
77
|
+
placeholder: "paperclip",
|
|
78
|
+
validate: (value) => {
|
|
79
|
+
if (!value || value.trim().length === 0) return "Bucket is required";
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (p.isCancel(bucket)) {
|
|
84
|
+
p.cancel("Setup cancelled.");
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const region = await p.text({
|
|
89
|
+
message: "S3 region",
|
|
90
|
+
defaultValue: base.s3.region || "us-east-1",
|
|
91
|
+
placeholder: "us-east-1",
|
|
92
|
+
validate: (value) => {
|
|
93
|
+
if (!value || value.trim().length === 0) return "Region is required";
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (p.isCancel(region)) {
|
|
98
|
+
p.cancel("Setup cancelled.");
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const endpoint = await p.text({
|
|
103
|
+
message: "S3 endpoint (optional for compatible backends)",
|
|
104
|
+
defaultValue: base.s3.endpoint ?? "",
|
|
105
|
+
placeholder: "https://s3.amazonaws.com",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (p.isCancel(endpoint)) {
|
|
109
|
+
p.cancel("Setup cancelled.");
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const prefix = await p.text({
|
|
114
|
+
message: "Object key prefix (optional)",
|
|
115
|
+
defaultValue: base.s3.prefix ?? "",
|
|
116
|
+
placeholder: "paperclip/",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (p.isCancel(prefix)) {
|
|
120
|
+
p.cancel("Setup cancelled.");
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const forcePathStyle = await p.confirm({
|
|
125
|
+
message: "Use S3 path-style URLs?",
|
|
126
|
+
initialValue: base.s3.forcePathStyle ?? false,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (p.isCancel(forcePathStyle)) {
|
|
130
|
+
p.cancel("Setup cancelled.");
|
|
131
|
+
process.exit(0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
provider: "s3",
|
|
136
|
+
localDisk: base.localDisk,
|
|
137
|
+
s3: {
|
|
138
|
+
bucket: bucket.trim(),
|
|
139
|
+
region: region.trim(),
|
|
140
|
+
endpoint: endpoint.trim() || undefined,
|
|
141
|
+
prefix: prefix.trim(),
|
|
142
|
+
forcePathStyle,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
TelemetryClient,
|
|
4
|
+
resolveTelemetryConfig,
|
|
5
|
+
loadOrCreateState,
|
|
6
|
+
trackInstallStarted,
|
|
7
|
+
trackInstallCompleted,
|
|
8
|
+
trackCompanyImported,
|
|
9
|
+
} from "../../packages/shared/src/telemetry/index.js";
|
|
10
|
+
import { resolvePaperclipInstanceRoot } from "./config/home.js";
|
|
11
|
+
import { readConfig } from "./config/store.js";
|
|
12
|
+
import { cliVersion } from "./version.js";
|
|
13
|
+
|
|
14
|
+
let client: TelemetryClient | null = null;
|
|
15
|
+
|
|
16
|
+
export function initTelemetry(fileConfig?: { enabled?: boolean }): TelemetryClient | null {
|
|
17
|
+
if (client) return client;
|
|
18
|
+
|
|
19
|
+
const config = resolveTelemetryConfig(fileConfig);
|
|
20
|
+
if (!config.enabled) return null;
|
|
21
|
+
|
|
22
|
+
const stateDir = path.join(resolvePaperclipInstanceRoot(), "telemetry");
|
|
23
|
+
client = new TelemetryClient(config, () => loadOrCreateState(stateDir, cliVersion), cliVersion);
|
|
24
|
+
return client;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function initTelemetryFromConfigFile(configPath?: string): TelemetryClient | null {
|
|
28
|
+
try {
|
|
29
|
+
return initTelemetry(readConfig(configPath)?.telemetry);
|
|
30
|
+
} catch {
|
|
31
|
+
return initTelemetry();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getTelemetryClient(): TelemetryClient | null {
|
|
36
|
+
return client;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function flushTelemetry(): Promise<void> {
|
|
40
|
+
if (client) {
|
|
41
|
+
await client.flush();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
trackInstallStarted,
|
|
47
|
+
trackInstallCompleted,
|
|
48
|
+
trackCompanyImported,
|
|
49
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
|
|
3
|
+
const PAPERCLIP_ART = [
|
|
4
|
+
"██████╗ █████╗ ██████╗ ███████╗██████╗ ██████╗██╗ ██╗██████╗ ",
|
|
5
|
+
"██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔════╝██║ ██║██╔══██╗",
|
|
6
|
+
"██████╔╝███████║██████╔╝█████╗ ██████╔╝██║ ██║ ██║██████╔╝",
|
|
7
|
+
"██╔═══╝ ██╔══██║██╔═══╝ ██╔══╝ ██╔══██╗██║ ██║ ██║██╔═══╝ ",
|
|
8
|
+
"██║ ██║ ██║██║ ███████╗██║ ██║╚██████╗███████╗██║██║ ",
|
|
9
|
+
"╚═╝ ╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚═╝╚═╝ ",
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
const TAGLINE = "Open-source orchestration for zero-human companies";
|
|
13
|
+
|
|
14
|
+
export function printPaperclipCliBanner(): void {
|
|
15
|
+
const lines = [
|
|
16
|
+
"",
|
|
17
|
+
...PAPERCLIP_ART.map((line) => pc.cyan(line)),
|
|
18
|
+
pc.blue(" ───────────────────────────────────────────────────────"),
|
|
19
|
+
pc.bold(pc.white(` ${TAGLINE}`)),
|
|
20
|
+
"",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
console.log(lines.join("\n"));
|
|
24
|
+
}
|
package/src/utils/net.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
|
|
3
|
+
export function checkPort(port: number): Promise<{ available: boolean; error?: string }> {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
const server = net.createServer();
|
|
6
|
+
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
7
|
+
if (err.code === "EADDRINUSE") {
|
|
8
|
+
resolve({ available: false, error: `Port ${port} is already in use` });
|
|
9
|
+
} else {
|
|
10
|
+
resolve({ available: false, error: err.message });
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
server.once("listening", () => {
|
|
14
|
+
server.close(() => resolve({ available: true }));
|
|
15
|
+
});
|
|
16
|
+
server.listen(port, "127.0.0.1");
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { expandHomePrefix } from "../config/home.js";
|
|
4
|
+
|
|
5
|
+
function unique(items: string[]): string[] {
|
|
6
|
+
return Array.from(new Set(items));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function resolveRuntimeLikePath(value: string, configPath?: string): string {
|
|
10
|
+
const expanded = expandHomePrefix(value);
|
|
11
|
+
if (path.isAbsolute(expanded)) return path.resolve(expanded);
|
|
12
|
+
|
|
13
|
+
const cwd = process.cwd();
|
|
14
|
+
const configDir = configPath ? path.dirname(configPath) : null;
|
|
15
|
+
const workspaceRoot = configDir ? path.resolve(configDir, "..") : cwd;
|
|
16
|
+
|
|
17
|
+
const candidates = unique([
|
|
18
|
+
...(configDir ? [path.resolve(configDir, expanded)] : []),
|
|
19
|
+
path.resolve(workspaceRoot, "server", expanded),
|
|
20
|
+
path.resolve(workspaceRoot, expanded),
|
|
21
|
+
path.resolve(cwd, expanded),
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0];
|
|
25
|
+
}
|
package/src/version.ts
ADDED
package/lib/downloader.js
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import got from "got";
|
|
2
|
-
import fs from "fs";
|
|
3
|
-
import fsa from "fs-extra";
|
|
4
|
-
import * as os from "node:os";
|
|
5
|
-
import tar from "tar";
|
|
6
|
-
import stream from "node:stream";
|
|
7
|
-
import { promisify } from "node:util";
|
|
8
|
-
import path from "path";
|
|
9
|
-
import { dirname } from "path";
|
|
10
|
-
import { fileURLToPath } from "url";
|
|
11
|
-
|
|
12
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
const debugMode = process.env.DEBUG != null;
|
|
14
|
-
const pipeline = promisify(stream.pipeline);
|
|
15
|
-
|
|
16
|
-
const pkg = JSON.parse(fs.readFileSync(__dirname + "/../package.json", "utf8"));
|
|
17
|
-
const CLI_FILENAME = "offwatch";
|
|
18
|
-
|
|
19
|
-
const logDebug = (message) => {
|
|
20
|
-
if (debugMode) {
|
|
21
|
-
console.debug(`[DEBUG] ${message}`);
|
|
22
|
-
}
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const getPlatform = () => {
|
|
26
|
-
const platform = os.platform();
|
|
27
|
-
if (platform === "win32") return "win32-x64";
|
|
28
|
-
if (platform === "darwin") return "darwin-x64";
|
|
29
|
-
return "linux-x64";
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const downloadRelease = async (versionDir) => {
|
|
33
|
-
logDebug(`downloadRelease(${versionDir})`);
|
|
34
|
-
const version = pkg.version;
|
|
35
|
-
const currentVersion = extractVersionParts(version);
|
|
36
|
-
|
|
37
|
-
const releases = (await got
|
|
38
|
-
.get(`https://api.github.com/repos/triss-smith/offwatch/releases`)
|
|
39
|
-
.json());
|
|
40
|
-
|
|
41
|
-
const matchingRelease = releases
|
|
42
|
-
.filter((release) => {
|
|
43
|
-
const releaseVersion = extractVersionParts(release.tag_name);
|
|
44
|
-
return (releaseVersion.major === currentVersion.major &&
|
|
45
|
-
releaseVersion.minor === currentVersion.minor);
|
|
46
|
-
})
|
|
47
|
-
.sort((a, b) => {
|
|
48
|
-
return Number(extractVersionParts(a.tag_name).patch) >
|
|
49
|
-
Number(extractVersionParts(b.tag_name).patch)
|
|
50
|
-
? -1
|
|
51
|
-
: 1;
|
|
52
|
-
})[0] || releases[0];
|
|
53
|
-
|
|
54
|
-
if (!matchingRelease) {
|
|
55
|
-
throw new Error(`No matching release for ${currentVersion.major}.${currentVersion.minor}`);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const platform = getPlatform();
|
|
59
|
-
const asset = matchingRelease.assets.find((asset) => {
|
|
60
|
-
return asset.name.includes(platform);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
if (!asset) {
|
|
64
|
-
throw new Error(`${platform} is not currently supported`);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
fsa.mkdirpSync(versionDir);
|
|
69
|
-
} catch (e) { }
|
|
70
|
-
|
|
71
|
-
const filePath = path.join(versionDir, CLI_FILENAME + ".mjs");
|
|
72
|
-
logDebug(`download(${asset.browser_download_url})`);
|
|
73
|
-
|
|
74
|
-
// For .js files, download to temp then rename
|
|
75
|
-
if (asset.name.endsWith(".js")) {
|
|
76
|
-
const tempPath = path.join(versionDir, asset.name);
|
|
77
|
-
await pipeline(got.stream(asset.browser_download_url), fs.createWriteStream(tempPath));
|
|
78
|
-
fs.renameSync(tempPath, filePath);
|
|
79
|
-
} else {
|
|
80
|
-
// For compressed files
|
|
81
|
-
const tarPath = path.join(versionDir, path.basename(asset.browser_download_url));
|
|
82
|
-
await pipeline(got.stream(asset.browser_download_url), fs.createWriteStream(tarPath));
|
|
83
|
-
await decompress(tarPath, filePath);
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const decompress = async (tarPath, outPath) => {
|
|
88
|
-
logDebug(`decompress(${tarPath})`);
|
|
89
|
-
await pipeline(fs.createReadStream(tarPath), tar.x({
|
|
90
|
-
C: path.dirname(outPath),
|
|
91
|
-
strip: 1,
|
|
92
|
-
}));
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
export const loadCLIBinPath = async (cwd) => {
|
|
96
|
-
const versionDir = path.join(cwd, pkg.version);
|
|
97
|
-
const binPath = path.join(versionDir, CLI_FILENAME + ".mjs");
|
|
98
|
-
logDebug(`loadCLIBinPath: ${binPath}`);
|
|
99
|
-
|
|
100
|
-
if (!fs.existsSync(binPath)) {
|
|
101
|
-
await downloadRelease(versionDir);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
logDebug(`returning ${binPath}`);
|
|
105
|
-
|
|
106
|
-
return binPath;
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
const extractVersionParts = (version) => {
|
|
110
|
-
const [major, minor, patch] = version.replace(/^v/, "").split(".");
|
|
111
|
-
return { major, minor, patch };
|
|
112
|
-
};
|
package/postinstall.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { loadCLIBinPath } from "./lib/downloader.js";
|
|
2
|
-
import { dirname } from "path";
|
|
3
|
-
import { fileURLToPath } from "url";
|
|
4
|
-
|
|
5
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
-
|
|
7
|
-
// Dumb check to see if dep. Want to skip this script if in workspace
|
|
8
|
-
if (process.cwd().includes("node_modules")) {
|
|
9
|
-
if (process.env.DEBUG) {
|
|
10
|
-
console.debug(`[DEBUG] download CLI release`);
|
|
11
|
-
}
|
|
12
|
-
loadCLIBinPath(__dirname).then(
|
|
13
|
-
() => {
|
|
14
|
-
process.exit(0);
|
|
15
|
-
},
|
|
16
|
-
(err) => {
|
|
17
|
-
console.error(err);
|
|
18
|
-
process.exit(1);
|
|
19
|
-
}
|
|
20
|
-
);
|
|
21
|
-
} else if (process.env.DEBUG) {
|
|
22
|
-
console.debug(`[DEBUG] skipping debug`);
|
|
23
|
-
}
|