@turns/dev 0.0.1

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/dist/index.js ADDED
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { applicationPaths, authenticate, CREDENTIAL_REFRESH_INTERVAL_MS, credentialsNeedRefresh, exchangeLegacyToken, normalizeOrigin, readCredentials, recoverRejectedCredentials, refreshCredentials, removeCredentials, uploadSocketUrl, writeCredentials, } from "./auth.js";
7
+ import { daemonStatus, installDaemon, uninstallDaemon } from "./daemon.js";
8
+ import { normalizeAllowedDirectories } from "./directory-policy.js";
9
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
10
+ const CLI_DIRECTORY = path.basename(HERE) === 'dist' ? path.dirname(HERE) : HERE;
11
+ const pkg = JSON.parse(fs.readFileSync(path.join(CLI_DIRECTORY, 'package.json'), 'utf8'));
12
+ export function migrateLegacyUploaderState(paths) {
13
+ const legacyDirectory = path.resolve(CLI_DIRECTORY, '../cloudflare');
14
+ const entries = [
15
+ [path.join(legacyDirectory, '.machine-id'), paths.machineId],
16
+ [path.join(legacyDirectory, '.native-upload-state.json'), paths.uploaderState],
17
+ ];
18
+ for (const [source, destination] of entries) {
19
+ if (fs.existsSync(destination) || !fs.existsSync(source))
20
+ continue;
21
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
22
+ const temporary = `${destination}.${process.pid}.tmp`;
23
+ fs.copyFileSync(source, temporary);
24
+ fs.chmodSync(temporary, 0o600);
25
+ fs.renameSync(temporary, destination);
26
+ }
27
+ }
28
+ export async function migrateLegacyCredentials(paths, origin, { legacyTokenFile = process.env.TURNS_LEGACY_TOKEN_FILE || path.resolve(CLI_DIRECTORY, '../cloudflare/.upload-token'), } = {}) {
29
+ const saved = readCredentials(paths);
30
+ if (saved) {
31
+ // Once access/refresh credentials exist, retaining the old bearer token is
32
+ // unnecessary secret material and surprising after `turns logout`.
33
+ try {
34
+ fs.unlinkSync(legacyTokenFile);
35
+ }
36
+ catch (error) {
37
+ if (error?.code !== 'ENOENT')
38
+ throw error;
39
+ }
40
+ return saved;
41
+ }
42
+ let legacyToken = '';
43
+ try {
44
+ legacyToken = fs.readFileSync(legacyTokenFile, 'utf8').trim();
45
+ }
46
+ catch { }
47
+ if (!legacyToken)
48
+ return null;
49
+ try {
50
+ const credentials = await exchangeLegacyToken(normalizeOrigin(origin || 'https://turns.dev'), legacyToken);
51
+ writeCredentials(credentials, paths);
52
+ fs.unlinkSync(legacyTokenFile);
53
+ process.stdout.write(`Upgraded the existing uploader credential for @${credentials.user.login}.\n`);
54
+ return credentials;
55
+ }
56
+ catch (error) {
57
+ process.stderr.write(`Could not upgrade the existing uploader credential: ${error.message}\n`);
58
+ return null;
59
+ }
60
+ }
61
+ function help() {
62
+ return `Turns ${pkg.version}
63
+
64
+ Usage:
65
+ turns Upload sessions for the current directory once
66
+ turns upload [directory] Watch sessions belonging to one directory
67
+ turns install Install and start the all-directory daemon
68
+ turns status Show daemon status
69
+ turns uninstall Stop and uninstall the background daemon
70
+ turns daemon run Run the daemon in the foreground
71
+ turns login Authenticate this machine in a browser
72
+ turns logout Remove saved credentials
73
+
74
+ Options:
75
+ --server URL Use a Turns server (saved with credentials)
76
+ --allow-directory PATH Restrict daemon uploads/launches to this prefix (repeatable)
77
+ --dev-user LOGIN Authenticate to a local Wrangler DEV_USER server
78
+ --no-browser Print the authentication URL without opening it
79
+
80
+ Environment:
81
+ TURNS_ORIGIN Server origin (default: saved origin or https://turns.dev)
82
+ TURNS_NO_BROWSER=1 Do not automatically open a browser
83
+ `;
84
+ }
85
+ function parseGlobalArguments(argv) {
86
+ const remaining = [];
87
+ let origin = process.env.TURNS_ORIGIN || process.env.TURNS_SERVER || '';
88
+ let devUser = '';
89
+ const allowedDirectories = [];
90
+ for (let index = 0; index < argv.length; index += 1) {
91
+ const argument = argv[index];
92
+ if (argument === '--server' || argument === '--origin') {
93
+ const value = argv[++index];
94
+ if (!value)
95
+ throw new Error(`${argument} requires a URL.`);
96
+ origin = value;
97
+ }
98
+ else if (argument === '--allow-directory') {
99
+ const value = argv[++index];
100
+ if (!value)
101
+ throw new Error(`${argument} requires a path.`);
102
+ allowedDirectories.push(value);
103
+ }
104
+ else if (argument === '--dev-user') {
105
+ const value = argv[++index];
106
+ if (!value)
107
+ throw new Error(`${argument} requires a login.`);
108
+ devUser = value.trim().toLowerCase();
109
+ }
110
+ else if (argument === '--no-browser') {
111
+ process.env.TURNS_NO_BROWSER = '1';
112
+ }
113
+ else {
114
+ remaining.push(argument);
115
+ }
116
+ }
117
+ return { argv: remaining, origin: origin || undefined, devUser, allowedDirectories };
118
+ }
119
+ function credentialProvider(initial, { paths, interactive }) {
120
+ let credentials = initial;
121
+ let refreshing = null;
122
+ const listeners = new Set();
123
+ const update = (value) => {
124
+ credentials = value;
125
+ for (const listener of listeners)
126
+ listener(value);
127
+ return value;
128
+ };
129
+ const refreshIfDue = async () => {
130
+ if (!credentialsNeedRefresh(credentials))
131
+ return credentials;
132
+ if (!refreshing) {
133
+ refreshing = refreshCredentials(credentials)
134
+ .then((value) => {
135
+ writeCredentials(value, paths);
136
+ return update(value);
137
+ })
138
+ .finally(() => {
139
+ refreshing = null;
140
+ });
141
+ }
142
+ return refreshing;
143
+ };
144
+ const timer = setInterval(() => {
145
+ refreshIfDue().catch((error) => {
146
+ process.stderr.write(`Turns credential refresh failed: ${error.message}; retrying later.\n`);
147
+ });
148
+ }, Math.min(CREDENTIAL_REFRESH_INTERVAL_MS, 60 * 60_000));
149
+ timer.unref?.();
150
+ return {
151
+ current: () => credentials,
152
+ refreshIfDue,
153
+ async recover() {
154
+ return update(await recoverRejectedCredentials(credentials, { paths, interactive }));
155
+ },
156
+ onUpdate(listener) {
157
+ listeners.add(listener);
158
+ return () => listeners.delete(listener);
159
+ },
160
+ close() {
161
+ clearInterval(timer);
162
+ },
163
+ };
164
+ }
165
+ /**
166
+ * @param {object} credentials
167
+ * @param {object} [options]
168
+ * @param {import('./auth.ts').AuthPaths} [options.paths]
169
+ * @param {string} [options.directory]
170
+ * @param {boolean} [options.watch]
171
+ * @param {boolean} [options.all]
172
+ * @param {boolean} [options.interactive]
173
+ * @param {string} [options.devUser]
174
+ * @param {string[]} [options.allowedDirectories]
175
+ */
176
+ async function runUpload(credentials, { paths, directory = '', watch = false, all = false, interactive = true, devUser = '', allowedDirectories = [], } = {}) {
177
+ migrateLegacyUploaderState(paths);
178
+ const provider = devUser ? null : credentialProvider(credentials, { paths, interactive });
179
+ const { runUploader } = await import("./upload-sessions.js");
180
+ const arguments_ = [
181
+ '--native',
182
+ '--url',
183
+ uploadSocketUrl(credentials.origin),
184
+ '--native-state-file',
185
+ paths.uploaderState,
186
+ '--machine-id-file',
187
+ paths.machineId,
188
+ ...(devUser ? ['--dev-user', devUser] : []),
189
+ ...allowedDirectories.flatMap((directory) => ['--allow-directory', directory]),
190
+ ...(watch ? ['--watch'] : []),
191
+ ...(all ? ['--all'] : allowedDirectories.length ? [] : ['--directory', path.resolve(directory || process.cwd())]),
192
+ ];
193
+ try {
194
+ await runUploader(arguments_, { credentialProvider: provider });
195
+ }
196
+ finally {
197
+ provider?.close();
198
+ }
199
+ }
200
+ async function main(rawArguments = process.argv.slice(2)) {
201
+ const parsed = parseGlobalArguments(rawArguments);
202
+ const argv = parsed.argv;
203
+ const command = argv[0] || '';
204
+ const paths = applicationPaths();
205
+ if (['--help', '-h', 'help'].includes(command)) {
206
+ process.stdout.write(help());
207
+ return;
208
+ }
209
+ if (['--version', '-v'].includes(command)) {
210
+ process.stdout.write(`${pkg.version}\n`);
211
+ return;
212
+ }
213
+ if (command === 'logout') {
214
+ removeCredentials(paths);
215
+ process.stdout.write('Removed saved Turns credentials.\n');
216
+ return;
217
+ }
218
+ if (command === 'login') {
219
+ const credentials = await authenticate({ origin: parsed.origin, forceBrowser: true, paths });
220
+ process.stdout.write(`Authenticated as @${credentials.user.login}.\n`);
221
+ return;
222
+ }
223
+ if (command === 'status') {
224
+ const status = daemonStatus();
225
+ process.stdout.write(`Turns daemon is ${status.active ? 'running' : status.status || 'not running'}.\n`);
226
+ process.exitCode = status.active ? 0 : 1;
227
+ return;
228
+ }
229
+ if (command === 'uninstall' || (command === 'daemon' && argv[1] === 'uninstall')) {
230
+ uninstallDaemon({ paths });
231
+ process.stdout.write('Turns daemon removed. Detached agent anchors were left running.\n');
232
+ return;
233
+ }
234
+ if (command === 'install') {
235
+ await migrateLegacyCredentials(paths, parsed.origin);
236
+ const credentials = await authenticate({ origin: parsed.origin, paths });
237
+ const service = installDaemon({ paths });
238
+ process.stdout.write(`Installed ${service}; syncing all local sessions as @${credentials.user.login}.\n`);
239
+ return;
240
+ }
241
+ if (command === 'daemon') {
242
+ if (argv[1] !== 'run')
243
+ throw new Error(`Unknown daemon command: ${argv[1] || ''}\n\n${help()}`);
244
+ const allowedDirectories = normalizeAllowedDirectories(parsed.allowedDirectories);
245
+ if (parsed.devUser && !parsed.origin)
246
+ throw new Error('--dev-user requires an explicit --server URL.');
247
+ let credentials;
248
+ if (parsed.devUser) {
249
+ credentials = { origin: normalizeOrigin(parsed.origin) };
250
+ }
251
+ else {
252
+ await migrateLegacyCredentials(paths, parsed.origin);
253
+ credentials = await authenticate({ origin: parsed.origin, paths, interactive: false });
254
+ }
255
+ if (allowedDirectories.length) {
256
+ process.stdout.write(`Daemon directory allowlist:\n${allowedDirectories.map((value) => ` ${value}`).join('\n')}\n`);
257
+ }
258
+ await runUpload(credentials, {
259
+ paths,
260
+ watch: true,
261
+ all: allowedDirectories.length === 0,
262
+ interactive: false,
263
+ devUser: parsed.devUser,
264
+ allowedDirectories,
265
+ });
266
+ return;
267
+ }
268
+ if (parsed.devUser || parsed.allowedDirectories.length) {
269
+ throw new Error('--dev-user and --allow-directory are supported only by `turns daemon run`.');
270
+ }
271
+ if (command && command !== 'upload')
272
+ throw new Error(`Unknown command: ${command}\n\n${help()}`);
273
+ const runningDaemon = daemonStatus();
274
+ if (runningDaemon.active) {
275
+ process.stdout.write('The Turns daemon is already syncing this directory as part of the all-directory watch.\n');
276
+ return;
277
+ }
278
+ await migrateLegacyCredentials(paths, parsed.origin);
279
+ const credentials = await authenticate({ origin: parsed.origin, paths });
280
+ const selectedDirectory = path.resolve(command === 'upload' ? argv[1] || process.cwd() : process.cwd());
281
+ process.stdout.write(`${command === 'upload' ? 'Watching' : 'Uploading'} sessions in ${selectedDirectory} as @${credentials.user.login}.\n`);
282
+ await runUpload(credentials, {
283
+ paths,
284
+ directory: selectedDirectory,
285
+ watch: command === 'upload',
286
+ interactive: true,
287
+ });
288
+ }
289
+ export { credentialProvider, help, main, parseGlobalArguments, runUpload };
290
+ let invokedAsMain = false;
291
+ try {
292
+ invokedAsMain = Boolean(process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)));
293
+ }
294
+ catch { }
295
+ if (invokedAsMain) {
296
+ main().catch((error) => {
297
+ process.stderr.write(`turns: ${error?.message || error}\n`);
298
+ process.exitCode = 1;
299
+ });
300
+ }