this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit

alice a61907d2

+676
+3
.gitignore
··· 1 + node_modules/ 2 + /workspace/.cache/ 3 + *.log
+64
README.md
··· 1 + # create-tangled-repo 2 + 3 + A small Bun + Playwright helper that creates Tangled repositories through Tangled's real appview flow. 4 + 5 + It logs in through the Bluesky OAuth page when needed, saves a reusable Tangled web session locally, and then creates repositories by posting to the same `/repo/new` route the web UI uses. 6 + 7 + ## Why this exists 8 + 9 + Tangled exposes lower-level repo creation plumbing, but creating a repo directly through the knot XRPC path can leave you with a repo that the appview does not ingest. This tool uses the appview-backed path so the created repo shows up properly in Tangled. 10 + 11 + ## Requirements 12 + 13 + - [Bun](https://bun.sh/) 14 + - A Tangled / Bluesky account 15 + - Your normal account password for the Bluesky auth flow 16 + - A Tangled knot where you can create repos 17 + 18 + The script downloads Playwright Chromium automatically on first run. 19 + 20 + ## Usage 21 + 22 + ```bash 23 + bun install 24 + ./create-tangled-repo.js --identifier alice.mosphere.at --password 'your-normal-password' my-repo 25 + ``` 26 + 27 + You can create more than one repo at once: 28 + 29 + ```bash 30 + ./create-tangled-repo.js repo-one repo-two 31 + ``` 32 + 33 + The script remembers the last knot you used. If no knot is configured yet, it defaults to `knot1.tangled.sh`. 34 + 35 + ## Options 36 + 37 + ```text 38 + --identifier <handle> Bluesky/Tangled handle for login, if a new session is needed 39 + --password <password> Account password for OAuth login, not an app password 40 + --knot <domain> Knot domain to host the repo on 41 + --branch <name> Default branch. Default: main 42 + --description <text> Optional repo description 43 + --host <url> Tangled appview base URL. Default: https://tangled.org 44 + --session-file <path> Stored session state file 45 + --config-file <path> Stored config file 46 + --login-only Refresh/login and save session, create nothing 47 + --show-browser Show the browser window during login 48 + ``` 49 + 50 + ## Cache files 51 + 52 + By default the script stores: 53 + 54 + - session state in `/workspace/.cache/tangled/session.json` 55 + - config in `/workspace/.cache/tangled/config.json` 56 + - Playwright browsers in `/workspace/.cache/ms-playwright` 57 + 58 + You can override those with environment variables. 59 + 60 + ## Notes 61 + 62 + - The password is your normal account password, not an app password. 63 + - App passwords are fine for the lower-level protocol path, but that path can create ghost repos. 64 + - This script intentionally takes the dirty path because it is the one that works today.
+19
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "create-tangled-repo", 7 + "dependencies": { 8 + "playwright": "^1.59.1", 9 + }, 10 + }, 11 + }, 12 + "packages": { 13 + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], 14 + 15 + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], 16 + 17 + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], 18 + } 19 + }
+574
create-tangled-repo.js
··· 1 + #!/usr/bin/env bun 2 + 3 + process.env.PLAYWRIGHT_BROWSERS_PATH ||= '/workspace/.cache/ms-playwright'; 4 + 5 + import { chromium } from 'playwright'; 6 + import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; 7 + import { dirname, join } from 'node:path'; 8 + 9 + const FALLBACK_KNOT = 'knot1.tangled.sh'; 10 + const DEFAULT_HOST = process.env.TANGLED_HOST || 'https://tangled.org'; 11 + const DEFAULT_SESSION_FILE = process.env.TANGLED_SESSION_FILE || '/workspace/.cache/tangled/session.json'; 12 + const DEFAULT_CONFIG_FILE = process.env.TANGLED_CONFIG_FILE || '/workspace/.cache/tangled/config.json'; 13 + const DEFAULT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '/workspace/.cache/ms-playwright'; 14 + 15 + const USAGE = `Usage: 16 + create-tangled-repo.js [options] <repo-name> [repo-name ...] 17 + 18 + Options: 19 + --identifier <handle> Bluesky/Tangled handle for login, if a new session is needed 20 + --password <password> Account password for OAuth login, not an app password 21 + --knot <domain> Knot domain to host the repo on 22 + Default: cached knot, otherwise ${FALLBACK_KNOT} 23 + --branch <name> Default branch. Default: main 24 + --description <text> Optional repo description 25 + --host <url> Tangled appview base URL. Default: ${DEFAULT_HOST} 26 + --session-file <path> Stored session state file. Default: ${DEFAULT_SESSION_FILE} 27 + --config-file <path> Stored config file. Default: ${DEFAULT_CONFIG_FILE} 28 + --login-only Refresh/login and save session, create nothing 29 + --show-browser Show the browser window during login 30 + --help Show this help 31 + 32 + Environment: 33 + TANGLED_IDENTIFIER Default for --identifier 34 + TANGLED_PASSWORD Default for --password 35 + TANGLED_KNOT_DOMAIN Default for --knot 36 + TANGLED_DEFAULT_BRANCH Default for --branch 37 + TANGLED_DESCRIPTION Default for --description 38 + TANGLED_HOST Default for --host 39 + TANGLED_SESSION_FILE Default for --session-file 40 + TANGLED_CONFIG_FILE Default for --config-file 41 + PLAYWRIGHT_BROWSERS_PATH Browser download path 42 + 43 + Notes: 44 + This script uses the same appview route as the web UI: POST /repo/new. 45 + It stores a reusable Tangled web session locally after the first successful login. 46 + It also remembers the last knot you used, so future runs can omit --knot. 47 + The password must be your normal account password for the Bluesky auth flow. 48 + App passwords are for the direct protocol route and do not create appview-visible repos. 49 + 50 + Examples: 51 + ./create-tangled-repo.js \ 52 + --identifier alice.mosphere.at \ 53 + --password 'your-account-password' \ 54 + pix 55 + 56 + ./create-tangled-repo.js --knot other-knot.example repo-one repo-two 57 + ./create-tangled-repo.js --login-only --identifier alice.mosphere.at --password 'your-account-password' 58 + `; 59 + 60 + function die(message, code = 1) { 61 + console.error(message); 62 + process.exit(code); 63 + } 64 + 65 + function parseJsonFile(path) { 66 + if (!existsSync(path)) return null; 67 + try { 68 + return JSON.parse(readFileSync(path, 'utf8')); 69 + } catch { 70 + return null; 71 + } 72 + } 73 + 74 + function parseArgs(argv) { 75 + const opts = { 76 + identifier: process.env.TANGLED_IDENTIFIER || '', 77 + password: process.env.TANGLED_PASSWORD || '', 78 + knot: process.env.TANGLED_KNOT_DOMAIN || '', 79 + branch: process.env.TANGLED_DEFAULT_BRANCH || 'main', 80 + description: process.env.TANGLED_DESCRIPTION || '', 81 + host: DEFAULT_HOST, 82 + sessionFile: DEFAULT_SESSION_FILE, 83 + configFile: DEFAULT_CONFIG_FILE, 84 + showBrowser: false, 85 + loginOnly: false, 86 + names: [], 87 + }; 88 + 89 + for (let i = 0; i < argv.length; i++) { 90 + const arg = argv[i]; 91 + switch (arg) { 92 + case '--help': 93 + case '-h': 94 + console.log(USAGE); 95 + process.exit(0); 96 + case '--identifier': 97 + opts.identifier = argv[++i] || ''; 98 + break; 99 + case '--password': 100 + opts.password = argv[++i] || ''; 101 + break; 102 + case '--knot': 103 + opts.knot = argv[++i] || ''; 104 + break; 105 + case '--branch': 106 + opts.branch = argv[++i] || ''; 107 + break; 108 + case '--description': 109 + opts.description = argv[++i] || ''; 110 + break; 111 + case '--host': 112 + opts.host = argv[++i] || ''; 113 + break; 114 + case '--session-file': 115 + opts.sessionFile = argv[++i] || ''; 116 + break; 117 + case '--config-file': 118 + opts.configFile = argv[++i] || ''; 119 + break; 120 + case '--show-browser': 121 + opts.showBrowser = true; 122 + break; 123 + case '--login-only': 124 + opts.loginOnly = true; 125 + break; 126 + default: 127 + if (arg.startsWith('--')) { 128 + die(`Unknown option: ${arg}\n\n${USAGE}`); 129 + } 130 + opts.names.push(arg); 131 + } 132 + } 133 + 134 + opts.host = (opts.host || DEFAULT_HOST).replace(/\/+$/, ''); 135 + opts.sessionFile = opts.sessionFile || DEFAULT_SESSION_FILE; 136 + opts.configFile = opts.configFile || DEFAULT_CONFIG_FILE; 137 + 138 + if (!opts.loginOnly && opts.names.length === 0) { 139 + die(`Missing repo name.\n\n${USAGE}`); 140 + } 141 + 142 + return opts; 143 + } 144 + 145 + function resolveDefaults(opts) { 146 + const config = parseJsonFile(opts.configFile) || {}; 147 + 148 + if (!opts.knot) { 149 + opts.knot = config.knot || FALLBACK_KNOT; 150 + } 151 + 152 + if (!opts.host && config.host) { 153 + opts.host = String(config.host).replace(/\/+$/, ''); 154 + } 155 + 156 + return config; 157 + } 158 + 159 + function saveConfig(path, nextValues) { 160 + ensureParentDir(path); 161 + const current = parseJsonFile(path) || {}; 162 + const merged = { 163 + ...current, 164 + ...Object.fromEntries(Object.entries(nextValues).filter(([, value]) => value)), 165 + }; 166 + Bun.write(path, JSON.stringify(merged, null, 2) + '\n'); 167 + } 168 + 169 + function validateRepoName(name) { 170 + if (!name) throw new Error('repository name cannot be empty'); 171 + if (name.endsWith('.git')) name = name.slice(0, -4); 172 + 173 + if ( 174 + name === '.' || 175 + name === '..' || 176 + name.includes('/') || 177 + name.includes('\\') || 178 + name.includes('./') || 179 + name.includes('../') || 180 + name.startsWith('.') || 181 + name.endsWith('.') || 182 + name.includes('..') 183 + ) { 184 + throw new Error(`invalid repository name '${name}'`); 185 + } 186 + 187 + for (const ch of name) { 188 + const ok = 189 + (ch >= 'a' && ch <= 'z') || 190 + (ch >= 'A' && ch <= 'Z') || 191 + (ch >= '0' && ch <= '9') || 192 + ch === '-' || 193 + ch === '_' || 194 + ch === '.'; 195 + if (!ok) { 196 + throw new Error(`repository name '${name}' can only contain letters, numbers, periods, hyphens, and underscores`); 197 + } 198 + } 199 + 200 + return name; 201 + } 202 + 203 + function ensureParentDir(path) { 204 + mkdirSync(dirname(path), { recursive: true }); 205 + } 206 + 207 + function parseStorageState(sessionFile) { 208 + return parseJsonFile(sessionFile); 209 + } 210 + 211 + function cookieHeaderForHost(host, storageState) { 212 + if (!storageState?.cookies?.length) return ''; 213 + const url = new URL(host); 214 + const hostname = url.hostname; 215 + const isHttps = url.protocol === 'https:'; 216 + const cookies = storageState.cookies.filter((cookie) => { 217 + if (!cookie.name || cookie.value == null) return false; 218 + const domain = String(cookie.domain || '').replace(/^\./, ''); 219 + const matchesDomain = hostname === domain || hostname.endsWith(`.${domain}`); 220 + const secureOk = !cookie.secure || isHttps; 221 + return matchesDomain && secureOk; 222 + }); 223 + return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; '); 224 + } 225 + 226 + async function fetchCreate(host, cookieHeader, knot, branch, description, repoName) { 227 + const body = new URLSearchParams(); 228 + body.set('domain', knot); 229 + body.set('name', repoName); 230 + body.set('branch', branch || 'main'); 231 + if (description) body.set('description', description); 232 + 233 + const res = await fetch(`${host}/repo/new`, { 234 + method: 'POST', 235 + headers: { 236 + 'content-type': 'application/x-www-form-urlencoded', 237 + 'cookie': cookieHeader, 238 + 'hx-request': 'true', 239 + 'referer': `${host}/repo/new`, 240 + }, 241 + body: body.toString(), 242 + redirect: 'manual', 243 + }); 244 + 245 + const text = await res.text(); 246 + return { 247 + status: res.status, 248 + body: text, 249 + hxLocation: res.headers.get('hx-location'), 250 + hxRedirect: res.headers.get('hx-redirect'), 251 + }; 252 + } 253 + 254 + function extractRepoError(html) { 255 + const match = html.match(/<span[^>]*id="repo"[^>]*>(.*?)<\/span>/is); 256 + if (!match) return ''; 257 + return match[1] 258 + .replace(/<[^>]+>/g, ' ') 259 + .replace(/&mdash;/g, '—') 260 + .replace(/&amp;/g, '&') 261 + .replace(/&lt;/g, '<') 262 + .replace(/&gt;/g, '>') 263 + .replace(/&#39;/g, "'") 264 + .replace(/&quot;/g, '"') 265 + .replace(/\s+/g, ' ') 266 + .trim(); 267 + } 268 + 269 + function needsAuthentication(resp) { 270 + return Boolean(resp.hxRedirect && resp.hxRedirect.includes('/login')); 271 + } 272 + 273 + function uniqueDirs(items) { 274 + return [...new Set(items.filter(Boolean))]; 275 + } 276 + 277 + function buildBrowserEnv() { 278 + const env = { ...process.env }; 279 + env.PLAYWRIGHT_BROWSERS_PATH = DEFAULT_BROWSERS_PATH; 280 + 281 + if (env.LD_LIBRARY_PATH && env.LD_LIBRARY_PATH.trim()) { 282 + return env; 283 + } 284 + 285 + const store = '/nix/store'; 286 + const patterns = [ 287 + { re: /-ld-library-path$/, suffix: 'share/nix-ld/lib' }, 288 + { re: /-system-path$/, suffix: 'lib' }, 289 + { re: /-nspr-/, suffix: 'lib' }, 290 + { re: /-nss-/, suffix: 'lib' }, 291 + { re: /-dbus-.*-lib$/, suffix: 'lib' }, 292 + { re: /-mesa-libgbm-/, suffix: 'lib' }, 293 + { re: /-libxkbcommon-/, suffix: 'lib' }, 294 + { re: /-alsa-lib-/, suffix: 'lib' }, 295 + { re: /-libxdamage-/, suffix: 'lib' }, 296 + ]; 297 + 298 + let entries = []; 299 + try { 300 + entries = readdirSync(store); 301 + } catch { 302 + entries = []; 303 + } 304 + 305 + const dirs = uniqueDirs( 306 + entries.flatMap((entry) => 307 + patterns 308 + .filter((pattern) => pattern.re.test(entry)) 309 + .map((pattern) => join(store, entry, pattern.suffix)) 310 + ) 311 + ); 312 + 313 + if (dirs.length > 0) { 314 + env.LD_LIBRARY_PATH = dirs.join(':'); 315 + } 316 + 317 + return env; 318 + } 319 + 320 + function browserCandidates() { 321 + const candidates = []; 322 + 323 + try { 324 + const entries = readdirSync(DEFAULT_BROWSERS_PATH); 325 + for (const entry of entries) { 326 + if (entry.startsWith('chromium-')) { 327 + candidates.push(join(DEFAULT_BROWSERS_PATH, entry, 'chrome-linux', 'chrome')); 328 + } 329 + if (entry.startsWith('chromium_headless_shell-')) { 330 + candidates.push(join(DEFAULT_BROWSERS_PATH, entry, 'chrome-linux', 'headless_shell')); 331 + } 332 + } 333 + } catch {} 334 + 335 + return candidates; 336 + } 337 + 338 + function findInstalledBrowserExecutable() { 339 + for (const candidate of browserCandidates()) { 340 + if (existsSync(candidate)) return candidate; 341 + } 342 + return ''; 343 + } 344 + 345 + async function ensureBrowserInstalled() { 346 + if (findInstalledBrowserExecutable()) return; 347 + 348 + console.error(`installing Playwright Chromium into ${DEFAULT_BROWSERS_PATH}...`); 349 + const proc = Bun.spawn([ 350 + 'bunx', 351 + 'playwright', 352 + 'install', 353 + 'chromium', 354 + ], { 355 + stdout: 'inherit', 356 + stderr: 'inherit', 357 + env: { 358 + ...process.env, 359 + PLAYWRIGHT_BROWSERS_PATH: DEFAULT_BROWSERS_PATH, 360 + }, 361 + }); 362 + 363 + const exitCode = await proc.exited; 364 + if (exitCode !== 0) { 365 + throw new Error(`Failed to install Playwright Chromium (exit ${exitCode})`); 366 + } 367 + 368 + if (!findInstalledBrowserExecutable()) { 369 + throw new Error(`Playwright reported success, but no Chromium executable was found under ${DEFAULT_BROWSERS_PATH}`); 370 + } 371 + } 372 + 373 + async function launchBrowser(headless) { 374 + await ensureBrowserInstalled(); 375 + const executablePath = findInstalledBrowserExecutable(); 376 + 377 + return await chromium.launch({ 378 + headless, 379 + executablePath, 380 + args: ['--no-sandbox'], 381 + env: buildBrowserEnv(), 382 + }); 383 + } 384 + 385 + async function loginAndSaveSession(opts) { 386 + if (!opts.identifier || !opts.password) { 387 + throw new Error('No valid Tangled session found. Re-run with --identifier and --password to create one. Use your normal account password, not an app password.'); 388 + } 389 + 390 + ensureParentDir(opts.sessionFile); 391 + 392 + console.error('starting Tangled login flow...'); 393 + const browser = await launchBrowser(!opts.showBrowser); 394 + const context = await browser.newContext(); 395 + const page = await context.newPage(); 396 + 397 + try { 398 + await page.goto(`${opts.host}/login`, { waitUntil: 'domcontentloaded' }); 399 + await page.locator('input[name="handle"]').fill(opts.identifier); 400 + await Promise.all([ 401 + page.waitForURL((url) => !url.toString().startsWith(`${opts.host}/login`), { timeout: 30000 }), 402 + page.locator('button#login-button').click(), 403 + ]); 404 + 405 + await page.waitForLoadState('domcontentloaded'); 406 + 407 + if (page.url().includes('/oauth/authorize')) { 408 + const passwordInput = page.locator('input[name="password"]'); 409 + await passwordInput.waitFor({ state: 'visible', timeout: 30000 }); 410 + await passwordInput.fill(opts.password); 411 + 412 + const remember = page.locator('input[name="remember"]'); 413 + if (await remember.count()) { 414 + try { 415 + await remember.check(); 416 + } catch {} 417 + } 418 + 419 + await page.getByRole('button', { name: /^sign in$/i }).click(); 420 + } 421 + 422 + const consentPattern = /^(continue|authorize|allow|approve|accept|grant access|continue as .+)$/i; 423 + const denyPattern = /^(cancel|back)$/i; 424 + const deadline = Date.now() + 90000; 425 + 426 + while (Date.now() < deadline) { 427 + const url = page.url(); 428 + if (url.startsWith(opts.host) && !url.includes('/login')) { 429 + await context.storageState({ path: opts.sessionFile }); 430 + saveConfig(opts.configFile, { host: opts.host, knot: opts.knot }); 431 + console.error(`saved session to ${opts.sessionFile}`); 432 + return; 433 + } 434 + 435 + const authError = await page.locator('text=/incorrect|invalid|try again|too many attempts/i').count(); 436 + if (authError > 0) { 437 + throw new Error('The auth server rejected the login. Check the username/password and try again.'); 438 + } 439 + 440 + const buttons = page.getByRole('button'); 441 + const count = await buttons.count(); 442 + let clicked = false; 443 + for (let i = 0; i < count; i++) { 444 + const button = buttons.nth(i); 445 + const text = (await button.innerText().catch(() => '')).trim(); 446 + if (!text || denyPattern.test(text)) continue; 447 + if (consentPattern.test(text)) { 448 + await button.click(); 449 + clicked = true; 450 + break; 451 + } 452 + } 453 + 454 + if (!clicked) { 455 + await page.waitForTimeout(500); 456 + } else { 457 + await page.waitForLoadState('domcontentloaded'); 458 + } 459 + } 460 + 461 + throw new Error('Timed out waiting for Tangled OAuth login to complete. Re-run with --show-browser if you need to watch the flow.'); 462 + } finally { 463 + await browser.close(); 464 + } 465 + } 466 + 467 + async function verifyRepoPage(location, storageState) { 468 + const cookieHeader = cookieHeaderForHost(location, storageState); 469 + const res = await fetch(location, { 470 + headers: cookieHeader ? { cookie: cookieHeader } : {}, 471 + redirect: 'manual', 472 + }); 473 + return { 474 + status: res.status, 475 + location: res.headers.get('location'), 476 + }; 477 + } 478 + 479 + async function createRepos(opts, storageState) { 480 + const cookieHeader = cookieHeaderForHost(opts.host, storageState); 481 + if (!cookieHeader) { 482 + throw new Error(`No usable cookies found in ${opts.sessionFile}. Re-run with --identifier and --password.`); 483 + } 484 + 485 + const results = []; 486 + for (const rawName of opts.names) { 487 + const repoName = validateRepoName(rawName); 488 + console.error(`creating ${repoName}...`); 489 + 490 + const resp = await fetchCreate(opts.host, cookieHeader, opts.knot, opts.branch, opts.description, repoName); 491 + if (needsAuthentication(resp)) { 492 + throw new Error('The saved Tangled session is no longer valid. Re-run with --identifier and --password to refresh it.'); 493 + } 494 + if (!resp.hxLocation) { 495 + const message = extractRepoError(resp.body); 496 + if (message) throw new Error(message); 497 + throw new Error(`Unexpected appview response for ${repoName}: HTTP ${resp.status}`); 498 + } 499 + 500 + const location = resp.hxLocation.startsWith('http') ? resp.hxLocation : `${opts.host}${resp.hxLocation}`; 501 + const verification = await verifyRepoPage(location, storageState); 502 + 503 + results.push({ 504 + name: repoName, 505 + location, 506 + verifyStatus: verification.status, 507 + redirectTo: verification.location, 508 + }); 509 + 510 + console.error(`created ${repoName}: ${location}`); 511 + } 512 + 513 + return results; 514 + } 515 + 516 + async function main() { 517 + const opts = parseArgs(process.argv.slice(2)); 518 + resolveDefaults(opts); 519 + saveConfig(opts.configFile, { host: opts.host, knot: opts.knot }); 520 + 521 + console.error(`using appview: ${opts.host}`); 522 + if (!opts.loginOnly) { 523 + console.error(`using knot: ${opts.knot}`); 524 + } 525 + 526 + let storageState = parseStorageState(opts.sessionFile); 527 + 528 + if (!storageState) { 529 + await loginAndSaveSession(opts); 530 + storageState = parseStorageState(opts.sessionFile); 531 + } 532 + 533 + if (opts.loginOnly) { 534 + console.log(JSON.stringify({ 535 + host: opts.host, 536 + knot: opts.knot, 537 + sessionFile: opts.sessionFile, 538 + configFile: opts.configFile, 539 + loggedIn: true, 540 + }, null, 2)); 541 + return; 542 + } 543 + 544 + try { 545 + const repos = await createRepos(opts, storageState); 546 + console.log(JSON.stringify({ 547 + host: opts.host, 548 + knot: opts.knot, 549 + sessionFile: opts.sessionFile, 550 + configFile: opts.configFile, 551 + repos, 552 + }, null, 2)); 553 + } catch (err) { 554 + const message = err?.message || String(err); 555 + if (message.includes('session is no longer valid') || message.includes('No usable cookies found')) { 556 + await loginAndSaveSession(opts); 557 + storageState = parseStorageState(opts.sessionFile); 558 + const repos = await createRepos(opts, storageState); 559 + console.log(JSON.stringify({ 560 + host: opts.host, 561 + knot: opts.knot, 562 + sessionFile: opts.sessionFile, 563 + configFile: opts.configFile, 564 + repos, 565 + }, null, 2)); 566 + return; 567 + } 568 + throw err; 569 + } 570 + } 571 + 572 + main().catch((err) => { 573 + die(`error: ${err?.message || err}`); 574 + });
+16
package.json
··· 1 + { 2 + "name": "create-tangled-repo", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "description": "Create Tangled repositories through the real appview flow", 7 + "bin": { 8 + "create-tangled-repo": "./create-tangled-repo.js" 9 + }, 10 + "scripts": { 11 + "start": "./create-tangled-repo.js --help" 12 + }, 13 + "dependencies": { 14 + "playwright": "^1.59.1" 15 + } 16 + }