From 915c10aca56273684e5da2c4e461ae99c39600d4 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Thu, 13 Aug 2026 06:33:08 +0100 Subject: [PATCH] feat: add non-interactive mode to pkgs CLIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes every CLI in the monorepo safe to run unattended from CI or an LLM agent — no more hanging on a stdin prompt or dropping into a menu with no TTY attached. Non-interactive context is detected via an explicit --non-interactive flag, CI=true, or a non-TTY stdin. - bismuth: guard the stdin-read path so it fails fast instead of hanging when no file arg is given and stdin is an interactive TTY - jasper: prompt() now refuses to block in non-interactive mode; the zero-arg → interactive-menu fallthrough now errors instead; new --gallery/--gallery-title/--gallery-description and --session flags close the two prompt sites that previously had no flag equivalent - malachite: same prompt() guard pattern; the no-substantive-args → interactive-menu gate now errors instead of prompting; existing -y/--yes-gated confirms get specific non-interactive error messages - opal: accepts --non-interactive as a no-op (already fully CI-safe) - tangled-sync: new --dry-run flag guards the git push, putRecord write, and README commit+push side effects - nix-config-tools/server-config (Rust): guard-only — refuses to run past --show when non-interactive, before any dialoguer prompt (including the one gating `sudo nixos-rebuild switch`) is reached Version bumps: bismuth 0.2.4→0.3.0, jasper 0.6.1→0.7.0, malachite 0.16.2→0.17.0, opal 0.2.1→0.3.0, tangled-sync 1.0.3→1.1.0, nix-config-tools 0.1.0→0.1.1 (patch — guard only, no new public flag) Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 2 +- packages/bismuth/package.json | 2 +- packages/bismuth/src/cli.ts | 16 +- packages/jasper/package.json | 2 +- packages/jasper/src/core/types.ts | 11 ++ packages/jasper/src/index.ts | 163 +++++++++++++----- packages/jasper/src/lib/auth.ts | 9 +- packages/jasper/src/lib/cli.ts | 28 +++ packages/jasper/src/utils/input.ts | 21 +++ packages/malachite/package.json | 2 +- packages/malachite/src/lib/auth.ts | 8 +- packages/malachite/src/lib/cli.ts | 25 ++- packages/malachite/src/types.ts | 1 + packages/malachite/src/utils/input.ts | 17 ++ packages/nix-config-tools/Cargo.toml | 2 +- .../nix-config-tools/src/bin/server-config.rs | 12 ++ packages/opal/package.json | 2 +- packages/opal/src/cli.ts | 6 + packages/tangled-sync/package.json | 2 +- packages/tangled-sync/src/index.ts | 77 ++++++--- 20 files changed, 326 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 22b1b1b..a66ef7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,7 +179,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "nix-config-tools" -version = "0.1.0" +version = "0.1.1" dependencies = [ "console", "dialoguer", diff --git a/packages/bismuth/package.json b/packages/bismuth/package.json index ac467af..b23d7c5 100644 --- a/packages/bismuth/package.json +++ b/packages/bismuth/package.json @@ -1,6 +1,6 @@ { "name": "@ewanc26/bismuth", - "version": "0.2.4", + "version": "0.3.0", "description": "Convert RTF-block documents (site.standard.document) to Markdown", "author": "Ewan Croft", "license": "AGPL-3.0-only", diff --git a/packages/bismuth/src/cli.ts b/packages/bismuth/src/cli.ts index 7db3147..652e007 100644 --- a/packages/bismuth/src/cli.ts +++ b/packages/bismuth/src/cli.ts @@ -68,6 +68,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise { 'no-frontmatter': { type: 'boolean', default: false }, pds: { type: 'string' }, help: { type: 'boolean', short: 'h', default: false }, + 'non-interactive': { type: 'boolean', default: false }, }, allowPositionals: false, strict: true, @@ -265,7 +273,9 @@ Commands: fetch Fetch all documents in a publication. Arguments: - file JSON file to read. Reads stdin if omitted. + file JSON file to read. Reads stdin if omitted (errors + immediately, instead of hanging, if stdin is an + interactive terminal with nothing piped to it). Options: -f, --frontmatter Emit YAML front matter from document metadata. @@ -274,6 +284,8 @@ Options: -o, --output FILE Write output to FILE instead of stdout. -h, --help Show this help text and exit. --version Print version and exit. + --non-interactive Accepted for consistency with other pkgs CLIs; + bismuth never prompts, so this is a no-op. Examples: # Convert a Standard.site document, with front matter @@ -310,6 +322,8 @@ Options: --no-frontmatter Omit YAML front matter from output files. --pds URL Override the auto-resolved PDS endpoint. -h, --help Show this help text and exit. + --non-interactive Accepted for consistency with other pkgs CLIs; + fetch never prompts, so this is a no-op. Examples: # Fetch all documents from a publication diff --git a/packages/jasper/package.json b/packages/jasper/package.json index be5b6ec..6286cf5 100644 --- a/packages/jasper/package.json +++ b/packages/jasper/package.json @@ -1,6 +1,6 @@ { "name": "@ewanc26/jasper", - "version": "0.6.1", + "version": "0.7.0", "description": "Convert Instagram data exports into posts, stories, and videos on Grain or Spark", "author": "Ewan Croft", "license": "AGPL-3.0-only", diff --git a/packages/jasper/src/core/types.ts b/packages/jasper/src/core/types.ts index 0ec273d..6bb016a 100644 --- a/packages/jasper/src/core/types.ts +++ b/packages/jasper/src/core/types.ts @@ -112,6 +112,12 @@ export interface ImportOptions { alt?: string; /** Target platform */ target: Target; + /** Use an existing gallery by AT-URI instead of prompting (Grain only) */ + gallery?: string; + /** Title for a newly created gallery, supplied non-interactively (Grain only) */ + galleryTitle?: string; + /** Description for a newly created gallery (Grain only) */ + galleryDescription?: string; } export interface ImportResult { @@ -167,6 +173,11 @@ export interface CommandLineArgs { resume?: boolean; listImports?: boolean; clearImports?: boolean; + gallery?: string; + galleryTitle?: string; + galleryDescription?: string; + session?: number; + nonInteractive?: boolean; } // ============================================ diff --git a/packages/jasper/src/index.ts b/packages/jasper/src/index.ts index 31c6647..10dfabf 100644 --- a/packages/jasper/src/index.ts +++ b/packages/jasper/src/index.ts @@ -13,7 +13,7 @@ import { import chalk from "chalk"; import { log, setGlobalLogger, Logger } from "./utils/logger.js"; import * as ui from "./utils/ui.js"; -import { prompt, confirm, select } from "./utils/input.js"; +import { prompt, confirm, select, isNonInteractive } from "./utils/input.js"; import { isZipFile, parseExport, @@ -153,6 +153,9 @@ async function runImport(options: { password?: string; dailyLimit?: number; resume?: boolean; + gallery?: string; + galleryTitle?: string; + galleryDescription?: string; }): Promise { const targetConfig = TARGET_CONFIGS[options.target]; ui.header( @@ -360,56 +363,104 @@ async function runImport(options: { ui.succeedSpinner(`Found ${existingGalleries.length} galleries`); log.blank(); - const galleryOptions = [ - "Create new gallery", - ...existingGalleries.map( - (g) => `${g.title} (${new Date(g.createdAt).toLocaleDateString()})`, - ), - ]; - - const choice = await select( - "Select gallery for import:", - galleryOptions, - 0, - ); - - if (choice === 0) { - // Create new gallery - const defaultTitle = `Instagram Import — ${new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`; - log.info(`Default title: ${defaultTitle}`); - const titleInput = await prompt( - `Gallery title (press Enter for default): `, - ); - const title = titleInput || defaultTitle; - const addDescription = await confirm("Add description?", false); - const description = addDescription - ? await prompt("Description: ") - : undefined; - + if (options.gallery) { + // --gallery : use directly, no prompt/select required + const match = existingGalleries.find((g) => g.uri === options.gallery); + if (!match) { + log.error(`Gallery not found: ${options.gallery}`); + log.error( + existingGalleries.length > 0 + ? `Existing galleries: ${existingGalleries.map((g) => g.uri).join(", ")}` + : "You have no existing galleries — omit --gallery to create one.", + ); + process.exit(1); + } + galleryUri = match!.uri; + galleryTitle = match!.title; + log.info(`Using gallery: ${galleryTitle}`); + log.blank(); + } else if (isNonInteractive()) { + if (!options.galleryTitle) { + log.error( + "Non-interactive mode: no gallery specified for this Grain import.", + ); + log.error( + "Pass --gallery to use an existing gallery, or --gallery-title " + + "[--gallery-description ] to create a new one.", + ); + log.error( + existingGalleries.length > 0 + ? `Existing galleries: ${existingGalleries.map((g) => g.uri).join(", ")}` + : "You have no existing galleries.", + ); + process.exit(1); + } log.progress("Creating gallery..."); const result = await publisher.createGallery( - title || defaultTitle, - description, + options.galleryTitle, + options.galleryDescription, ); if (result.success && result.uri) { galleryUri = result.uri; - galleryTitle = title || defaultTitle; + galleryTitle = options.galleryTitle; ui.succeedSpinner(`Created gallery: ${galleryTitle}`); } else { log.error(`Failed to create gallery: ${result.error}`); process.exit(1); } + log.blank(); } else { - // Use existing gallery - galleryUri = existingGalleries[choice - 1]?.uri; - galleryTitle = existingGalleries[choice - 1]?.title; - if (!galleryUri) { - log.error("Invalid gallery selection"); - process.exit(1); + const galleryOptions = [ + "Create new gallery", + ...existingGalleries.map( + (g) => `${g.title} (${new Date(g.createdAt).toLocaleDateString()})`, + ), + ]; + + const choice = await select( + "Select gallery for import:", + galleryOptions, + 0, + ); + + if (choice === 0) { + // Create new gallery + const defaultTitle = `Instagram Import — ${new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`; + log.info(`Default title: ${defaultTitle}`); + const titleInput = await prompt( + `Gallery title (press Enter for default): `, + ); + const title = titleInput || defaultTitle; + const addDescription = await confirm("Add description?", false); + const description = addDescription + ? await prompt("Description: ") + : undefined; + + log.progress("Creating gallery..."); + const result = await publisher.createGallery( + title || defaultTitle, + description, + ); + if (result.success && result.uri) { + galleryUri = result.uri; + galleryTitle = title || defaultTitle; + ui.succeedSpinner(`Created gallery: ${galleryTitle}`); + } else { + log.error(`Failed to create gallery: ${result.error}`); + process.exit(1); + } + } else { + // Use existing gallery + galleryUri = existingGalleries[choice - 1]?.uri; + galleryTitle = existingGalleries[choice - 1]?.title; + if (!galleryUri) { + log.error("Invalid gallery selection"); + process.exit(1); + } + log.info(`Using gallery: ${galleryTitle}`); } - log.info(`Using gallery: ${galleryTitle}`); + log.blank(); } - log.blank(); } } @@ -918,11 +969,27 @@ export async function main(): Promise { } log.blank(); - const choice = await prompt("Which import to resume? (number): "); - const index = parseInt(choice, 10) - 1; - if (index < 0 || index >= states.length) { - log.error("Invalid selection"); + let index: number; + if (args.session !== undefined) { + index = args.session - 1; + if (index < 0 || index >= states.length) { + log.error( + `Invalid --session ${args.session}. There are ${states.length} pending session(s) (1-${states.length}).`, + ); + process.exit(1); + } + } else if (isNonInteractive()) { + log.error( + `Multiple pending import sessions found (${states.length}). --session is required to pick one in non-interactive mode (see the list above).`, + ); process.exit(1); + } else { + const choice = await prompt("Which import to resume? (number): "); + index = parseInt(choice, 10) - 1; + if (index < 0 || index >= states.length) { + log.error("Invalid selection"); + process.exit(1); + } } await runImport({ @@ -961,6 +1028,18 @@ export async function main(): Promise { return; } + // No input provided + if (isNonInteractive()) { + log.error( + "No arguments provided and running non-interactively (CI, no TTY, or --non-interactive).", + ); + log.error( + "Provide -i/--input , or one of --oauth-login / --list-sessions / --resume.", + ); + log.error("Run `jasper --help` for full usage."); + process.exit(1); + } + // No input provided — run interactive mode await runInteractive(); } diff --git a/packages/jasper/src/lib/auth.ts b/packages/jasper/src/lib/auth.ts index 57aef68..73c6ffe 100644 --- a/packages/jasper/src/lib/auth.ts +++ b/packages/jasper/src/lib/auth.ts @@ -3,7 +3,7 @@ * Supports OAuth (recommended) and app password fallback */ import { AtpAgent, Agent } from "@atproto/api"; -import { prompt } from "../utils/input.js"; +import { prompt, isNonInteractive } from "../utils/input.js"; import * as ui from "../utils/ui.js"; import { log } from "../utils/logger.js"; import { @@ -188,6 +188,13 @@ export async function authenticate( // Fall back to app password if (!handle || !password) { + if (isNonInteractive()) { + log.error( + "Authentication requires --handle and --password in non-interactive mode (or a stored OAuth session).", + ); + process.exit(1); + } + log.blank(); log.info("Please provide your credentials:"); log.blank(); diff --git a/packages/jasper/src/lib/cli.ts b/packages/jasper/src/lib/cli.ts index 6c23921..59597ef 100644 --- a/packages/jasper/src/lib/cli.ts +++ b/packages/jasper/src/lib/cli.ts @@ -24,10 +24,14 @@ ${chalk.bold("OPTIONS")} -v, --verbose Enable debug logging -q, --quiet Suppress non-essential output -y, --yes Skip confirmation prompts + --non-interactive Fail fast instead of prompting (auto-detected in + CI, or when stdin isn't a TTY) ${chalk.bold("DAILY LIMIT OPTIONS")} --daily-limit Maximum posts to import per day (default: 100) --resume Resume previous import session + --session Which pending session to resume, when --resume + matches multiple (see --list-imports) --list-imports List pending import sessions --clear-imports Clear all saved import state @@ -38,6 +42,14 @@ ${chalk.bold("AUTH OPTIONS")} --handle AT Protocol handle for app password login --password App password for login +${chalk.bold("GRAIN GALLERY OPTIONS")} + --gallery Use an existing gallery (skips gallery prompt) + --gallery-title Title for a newly created gallery + (required in --non-interactive mode when + --gallery isn't given) + --gallery-description Optional description for a newly created + gallery + ${chalk.bold("EXAMPLES")} ${chalk.gray("# Interactive mode")} jasper @@ -66,6 +78,9 @@ ${chalk.bold("EXAMPLES")} ${chalk.gray("# Sign in with app password (non-interactive)")} jasper -i export.zip --handle your.handle --password app-password + ${chalk.gray("# CI / LLM-safe: never prompts, fails fast on missing input")} + jasper -i export.zip --handle h --password p -y --gallery-title "Import" --non-interactive + ${chalk.bold("MORE INFO")} ${chalk.gray("Privacy:")} https://github.com/ewanc26/pkgs/tree/main/packages/jasper/PRIVACY.md ${chalk.gray("Issues:")} https://github.com/ewanc26/pkgs/issues @@ -97,6 +112,11 @@ export function parseCliArgs(argv: string[]): CommandLineArgs { resume: { type: "boolean" }, "list-imports": { type: "boolean" }, "clear-imports": { type: "boolean" }, + gallery: { type: "string" }, + "gallery-title": { type: "string" }, + "gallery-description": { type: "string" }, + session: { type: "string" }, + "non-interactive": { type: "boolean" }, }, strict: false, }) as { values: Record }; @@ -126,6 +146,11 @@ export function parseCliArgs(argv: string[]): CommandLineArgs { resume: values.resume as boolean | undefined, listImports: values["list-imports"] as boolean | undefined, clearImports: values["clear-imports"] as boolean | undefined, + gallery: values.gallery as string | undefined, + galleryTitle: values["gallery-title"] as string | undefined, + galleryDescription: values["gallery-description"] as string | undefined, + session: values.session ? parseInt(values.session as string, 10) : undefined, + nonInteractive: values["non-interactive"] as boolean | undefined, }; } @@ -143,6 +168,9 @@ export function argsToImportOptions(args: CommandLineArgs): ImportOptions { quiet: args.quiet || false, alt: args.alt, target: args.target || "grain", + gallery: args.gallery, + galleryTitle: args.galleryTitle, + galleryDescription: args.galleryDescription, }; } diff --git a/packages/jasper/src/utils/input.ts b/packages/jasper/src/utils/input.ts index 2afdb0c..88be679 100644 --- a/packages/jasper/src/utils/input.ts +++ b/packages/jasper/src/utils/input.ts @@ -15,12 +15,33 @@ function createReadlineInterface(): readline.Interface { }); } +/** + * True when jasper should never block on stdin: explicit --non-interactive, + * a CI environment, or stdin isn't a TTY (piped/redirected/no terminal). + */ +export function isNonInteractive(): boolean { + return ( + process.argv.includes("--non-interactive") || + Boolean(process.env.CI) || + !process.stdin.isTTY + ); +} + /** * Prompt user for input * @param message The prompt message * @param hidden Whether to hide the input (for passwords) */ export async function prompt(message: string, hidden = false): Promise { + if (isNonInteractive()) { + console.error( + `jasper: refusing to prompt in non-interactive mode: "${message.trim()}"`, + ); + console.error( + "Pass the required flag(s) explicitly, or run jasper --help for usage.", + ); + process.exit(1); + } return new Promise((resolve) => { const rl = createReadlineInterface(); diff --git a/packages/malachite/package.json b/packages/malachite/package.json index ab58aee..cc8e33b 100644 --- a/packages/malachite/package.json +++ b/packages/malachite/package.json @@ -1,6 +1,6 @@ { "name": "@ewanc26/malachite", - "version": "0.16.2", + "version": "0.17.0", "description": "Import Last.fm and Spotify listening history to ATProto with intelligent deduplication and rate limiting", "author": "Ewan Croft", "license": "AGPL-3.0-only", diff --git a/packages/malachite/src/lib/auth.ts b/packages/malachite/src/lib/auth.ts index d756669..89f2e40 100644 --- a/packages/malachite/src/lib/auth.ts +++ b/packages/malachite/src/lib/auth.ts @@ -5,7 +5,7 @@ import type { Agent } from '@atproto/api'; import { login as coreLogin, resolveIdentity } from '@ewanc26/croft-click-core'; -import { prompt } from '../utils/input.js'; +import { prompt, isNonInteractive } from '../utils/input.js'; import * as ui from '../utils/ui.js'; import { saveCredentials } from '../utils/credentials.js'; @@ -22,6 +22,12 @@ export async function login( ): Promise { ui.header('ATProto Login'); + if ((!identifier || !password) && isNonInteractive()) { + throw new Error( + 'Authentication requires credentials in non-interactive mode. Pass -h/--handle and -p/--password explicitly, or run --oauth-login.' + ); + } + if (!identifier) { identifier = await prompt('Handle, DID (did:plc or did:web): '); } else { diff --git a/packages/malachite/src/lib/cli.ts b/packages/malachite/src/lib/cli.ts index f6e1fef..e0ddf0f 100644 --- a/packages/malachite/src/lib/cli.ts +++ b/packages/malachite/src/lib/cli.ts @@ -18,7 +18,7 @@ import { parseYouTubeMusicJson, convertYouTubeMusicToPlayRecord } from '../lib/y import { parseListenBrainzJson, convertListenBrainzToPlayRecord } from '../lib/listenbrainz.js'; import { parseCombinedExports } from '../lib/merge.js'; import { publishRecordsWithApplyWrites } from './publisher.js'; -import { prompt, confirm, promptWithValidation, validateFilePath } from '../utils/input.js'; +import { prompt, confirm, promptWithValidation, validateFilePath, isNonInteractive } from '../utils/input.js'; import { sortRecords } from '../utils/helpers.js'; import config, { VERSION, RECORD_TYPE, LEGACY_RECORD_TYPE } from '../config.js'; import { fetchExistingRecords, filterNewRecords, displaySyncStats, removeDuplicates, deduplicateInputRecords } from './sync.js'; @@ -102,6 +102,7 @@ ${'\x1b[1m'}OUTPUT:${'\x1b[0m'} -v, --verbose Enable verbose logging (debug level) -q, --quiet Suppress non-essential output --dev Development mode (verbose + file logging + smaller batches) + --non-interactive Fail fast instead of prompting (auto-detected in CI / no TTY) --help Show this help message ${'\x1b[1m'}EXAMPLES:${'\x1b[0m'} @@ -184,6 +185,7 @@ export function parseCommandLineArgs(): CommandLineArgs { verbose: { type: 'boolean', short: 'v', default: false }, quiet: { type: 'boolean', short: 'q', default: false }, dev: { type: 'boolean', default: false }, + 'non-interactive': { type: 'boolean', default: false }, file: { type: 'string', short: 'f' }, 'spotify-file': { type: 'string' }, identifier: { type: 'string' }, @@ -222,6 +224,7 @@ export function parseCommandLineArgs(): CommandLineArgs { verbose: values.verbose, quiet: values.quiet, dev: values.dev, + 'non-interactive': values['non-interactive'], }; if (values.mode) { @@ -544,7 +547,7 @@ export async function runCLI(): Promise { // Check if running with no arguments (interactive mode) // Modifier flags like --dry-run, --verbose, --yes, etc. don't count as "real" arguments - const modifierFlags = ['dry-run', 'verbose', 'quiet', 'yes', 'reverse', 'aggressive', 'fresh', 'dev']; + const modifierFlags = ['dry-run', 'verbose', 'quiet', 'yes', 'reverse', 'aggressive', 'fresh', 'dev', 'non-interactive']; const hasSubstantiveArgs = Object.keys(args).some(key => { const value = args[key as keyof CommandLineArgs]; // Skip undefined, false values, and default mode @@ -559,6 +562,12 @@ export async function runCLI(): Promise { }); if (!hasSubstantiveArgs) { + if (isNonInteractive()) { + console.error('malachite: no arguments provided and running non-interactively (CI, no TTY, or --non-interactive).'); + console.error('Provide -m/--mode and -i/--input (or --spotify-input etc.), or --oauth-login / --list-sessions.'); + console.error('Run malachite --help for full usage.'); + process.exit(1); + } // No substantive arguments provided - run interactive mode args = await runInteractiveMode(); } @@ -717,6 +726,9 @@ export async function runCLI(): Promise { log.warn(`This will permanently delete ${result.totalDuplicates} duplicate records from Teal.`); log.info('The first occurrence of each duplicate will be kept.'); log.blank(); + if (isNonInteractive()) { + throw new Error('Deduplicate mode requires confirmation. Pass -y/--yes to proceed, or run interactively.'); + } const answer = await prompt('Are you sure you want to continue? (y/N) '); if (answer.toLowerCase() !== 'y') { log.info('Duplicate removal cancelled by user.'); @@ -779,6 +791,9 @@ export async function runCLI(): Promise { log.warn(`This will backfill ${plan.toBackfill.length.toLocaleString()} record(s) into ${RECORD_TYPE}`); log.warn(`and permanently delete ${plan.legacyTotal.toLocaleString()} legacy ${LEGACY_RECORD_TYPE} record(s).`); log.blank(); + if (isNonInteractive()) { + throw new Error('Polish mode requires confirmation. Pass -y/--yes to proceed, or run interactively.'); + } const answer = await prompt('Are you sure you want to continue? (y/N) '); if (answer.toLowerCase() !== 'y') { log.info('Migration cancelled by user.'); @@ -966,6 +981,9 @@ export async function runCLI(): Promise { if (importState && !importState.completed) { displayResumeInfo(importState); if (!args.yes) { + if (isNonInteractive()) { + throw new Error('Resuming a previous import requires confirmation. Pass -y/--yes to auto-resume, or --fresh to start over.'); + } const answer = await prompt('Resume from previous import? (Y/n) '); if (answer.toLowerCase() === 'n') { importState = null; @@ -993,6 +1011,9 @@ export async function runCLI(): Promise { const modeLabel = mode === 'combined' ? 'merged' : mode === 'sync' ? 'new' : ''; const skippedInfo = mode === 'sync' ? ` (${rawRecordCount - totalRecords} skipped)` : ''; log.raw(`Ready to publish ${totalRecords.toLocaleString()} ${modeLabel} records${skippedInfo}`); + if (isNonInteractive()) { + throw new Error('Publishing requires confirmation. Pass -y/--yes to proceed, or run interactively.'); + } const answer = await prompt('Continue? (y/N) '); if (answer.toLowerCase() !== 'y') { log.info('Cancelled by user.'); diff --git a/packages/malachite/src/types.ts b/packages/malachite/src/types.ts index 71b46d4..6091c92 100644 --- a/packages/malachite/src/types.ts +++ b/packages/malachite/src/types.ts @@ -62,6 +62,7 @@ export interface CommandLineArgs { 'dry-run'?: boolean; aggressive?: boolean; fresh?: boolean; + 'non-interactive'?: boolean; 'clear-cache'?: boolean; 'clear-all-caches'?: boolean; 'clear-credentials'?: boolean; diff --git a/packages/malachite/src/utils/input.ts b/packages/malachite/src/utils/input.ts index 73292cc..079c67e 100644 --- a/packages/malachite/src/utils/input.ts +++ b/packages/malachite/src/utils/input.ts @@ -3,6 +3,18 @@ import chalk from 'chalk'; import * as fs from 'fs'; import * as path from 'path'; +/** + * True when malachite should never block on stdin: explicit --non-interactive, + * a CI environment, or stdin isn't a TTY (piped/redirected/no terminal). + */ +export function isNonInteractive(): boolean { + return ( + process.argv.includes('--non-interactive') || + Boolean(process.env.CI) || + !process.stdin.isTTY + ); +} + /** * Validate if a file or directory exists */ @@ -155,6 +167,11 @@ export async function confirm(question: string, defaultYes = false): Promise { + if (isNonInteractive()) { + console.error(`malachite: refusing to prompt in non-interactive mode: "${question.trim()}"`); + console.error('Pass the required flag(s) explicitly, or run malachite --help for usage.'); + process.exit(1); + } return new Promise((resolve) => { if (hideInput) { // For password input, use raw mode diff --git a/packages/nix-config-tools/Cargo.toml b/packages/nix-config-tools/Cargo.toml index 22991ee..015f06c 100644 --- a/packages/nix-config-tools/Cargo.toml +++ b/packages/nix-config-tools/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nix-config-tools" -version = "0.1.0" +version = "0.1.1" edition = "2021" authors = ["Ewan Croft"] license = "MIT" diff --git a/packages/nix-config-tools/src/bin/server-config.rs b/packages/nix-config-tools/src/bin/server-config.rs index c072109..5a63175 100644 --- a/packages/nix-config-tools/src/bin/server-config.rs +++ b/packages/nix-config-tools/src/bin/server-config.rs @@ -12,6 +12,7 @@ use console::Style; use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect, Select}; use regex::Regex; +use std::io::IsTerminal; use tools_common::*; // ── helpers ────────────────────────────────────────────────────────────────── @@ -552,6 +553,17 @@ fn main() { return; } + let non_interactive = args.iter().any(|a| a == "--non-interactive") + || env::var("CI").is_ok() + || !std::io::stdin().is_terminal(); + + if non_interactive { + eprintln!("❌ server-config requires an interactive terminal."); + eprintln!(" Use --show to inspect the current config, or edit the NixOS modules"); + eprintln!(" under modules/server/services/ directly."); + std::process::exit(1); + } + let menu_items = [ "Service toggles (forgejo / pds / matrix / cloudflare)", "/srv storage (block device, filesystem)", diff --git a/packages/opal/package.json b/packages/opal/package.json index 1aa20fd..ab0ec68 100644 --- a/packages/opal/package.json +++ b/packages/opal/package.json @@ -1,6 +1,6 @@ { "name": "@ewanc26/opal", - "version": "0.2.1", + "version": "0.3.0", "description": "Convert microblog posts from Twitter, Mastodon, Threads, and Nostr to AT Protocol Bluesky posts", "author": "Ewan Croft", "license": "AGPL-3.0-only", diff --git a/packages/opal/src/cli.ts b/packages/opal/src/cli.ts index 6e40e51..f083237 100644 --- a/packages/opal/src/cli.ts +++ b/packages/opal/src/cli.ts @@ -18,6 +18,7 @@ interface CliArgs extends Partial { help?: boolean; handle?: string; password?: string; + nonInteractive?: boolean; } function parseArgs(argv: string[]): CliArgs { @@ -35,6 +36,10 @@ function parseArgs(argv: string[]): CliArgs { opts.publish = true; continue; } + if (arg === '--non-interactive') { + opts.nonInteractive = true; // accepted for wrapper-script consistency; opal has no prompts + continue; + } if (arg.startsWith('--') && i + 1 < argv.length) { const key = arg.slice(2); opts[key] = argv[++i]; @@ -64,6 +69,7 @@ Options: --handle AT Protocol handle or DID (required for --publish) --password App password (required for --publish) --dry-run Show what would be published without publishing + --non-interactive No-op — accepted for consistency with other pkgs CLIs -h, --help Show this help message Examples: diff --git a/packages/tangled-sync/package.json b/packages/tangled-sync/package.json index 4f837ee..de9970b 100644 --- a/packages/tangled-sync/package.json +++ b/packages/tangled-sync/package.json @@ -1,6 +1,6 @@ { "name": "@ewanc26/tangled-sync", - "version": "1.0.3", + "version": "1.1.0", "description": "Sync GitHub repos to Tangled with ATProto records", "author": "Ewan Croft", "license": "AGPL-3.0-only", diff --git a/packages/tangled-sync/src/index.ts b/packages/tangled-sync/src/index.ts index efa713f..2e3973c 100644 --- a/packages/tangled-sync/src/index.ts +++ b/packages/tangled-sync/src/index.ts @@ -17,6 +17,8 @@ import { execSync } from "child_process"; dotenv.config(); const FORCE_SYNC = process.argv.includes("--force"); +const DRY_RUN = process.argv.includes("--dry-run"); +const NON_INTERACTIVE = process.argv.includes("--non-interactive"); const BASE_DIR = process.env.BASE_DIR!; const GITHUB_USER = process.env.GITHUB_USER!; @@ -53,7 +55,7 @@ async function getGitHubRepos(): Promise<{ clone_url: string; name: string; desc .map((r: any) => ({ clone_url: r.clone_url, name: r.name, description: r.description })); } -async function ensureTangledRemoteAndPush(repoDir: string, repoName: string, cloneUrl: string) { +async function ensureTangledRemoteAndPush(repoDir: string, repoName: string, cloneUrl: string, dryRun: boolean) { const tangledUrl = `${TANGLED_BASE_URL}/${repoName}`; try { const remotes = run("git remote", repoDir).split("\n"); @@ -68,8 +70,12 @@ async function ensureTangledRemoteAndPush(repoDir: string, repoName: string, clo console.log(`[REMOTE] Reset origin push URL to GitHub`); } - run(`git push tangled main`, repoDir); - console.log(`[PUSH] Pushed main to Tangled`); + if (dryRun) { + console.log(`[DRY-RUN] Would push main to Tangled remote ${tangledUrl}`); + } else { + run(`git push tangled main`, repoDir); + console.log(`[PUSH] Pushed main to Tangled`); + } } catch (error) { console.warn(`[WARN] Could not push ${repoName} to Tangled. Check SSH or repo existence.`); } @@ -134,7 +140,8 @@ async function ensureTangledRecord( atprotoDid: string, githubUser: string, repoName: string, - description?: string + description: string | undefined, + dryRun: boolean ): Promise<{ tid: string; existed: boolean }> { if (recordCache[repoName]) { return { tid: recordCache[repoName], existed: true }; @@ -179,28 +186,35 @@ async function ensureTangledRecord( labels: [], }; - try { - const result = await agent.api.com.atproto.repo.putRecord({ - repo: atprotoDid, - collection: "sh.tangled.repo", - rkey: tid, - record, - }); - console.log(`[CREATED] ATProto record URI: ${result.data.uri}`); - } catch (error: any) { - console.error(`[ERROR] Failed to create ATProto record for ${repoName}:`, error.message); - throw error; + if (dryRun) { + console.log( + `[DRY-RUN] Would create ATProto record sh.tangled.repo for ${repoName} (rkey: ${tid}, repo: ${atprotoDid})` + ); + } else { + try { + const result = await agent.api.com.atproto.repo.putRecord({ + repo: atprotoDid, + collection: "sh.tangled.repo", + rkey: tid, + record, + }); + console.log(`[CREATED] ATProto record URI: ${result.data.uri}`); + } catch (error: any) { + console.error(`[ERROR] Failed to create ATProto record for ${repoName}:`, error.message); + throw error; + } + + console.log(`[CREATED] Tangled record for ${repoName} (TID: ${tid})`); } recordCache[repoName] = tid; - console.log(`[CREATED] Tangled record for ${repoName} (TID: ${tid})`); return { tid, existed: false }; } return { tid, existed: false }; } -function updateReadme(baseDir: string, repoName: string, atprotoDid: string) { +function updateReadme(baseDir: string, repoName: string, atprotoDid: string, dryRun: boolean) { const repoDir = path.join(baseDir, repoName); const readmeFiles = ["README.md", "README.MD", "README.txt", "README"]; const readmeFile = readmeFiles.find((f) => fs.existsSync(path.join(repoDir, f))); @@ -208,16 +222,20 @@ function updateReadme(baseDir: string, repoName: string, atprotoDid: string) { const readmePath = path.join(repoDir, readmeFile); const content = fs.readFileSync(readmePath, "utf-8"); if (!/tangled\.org/i.test(content)) { - fs.appendFileSync( - readmePath, - ` + if (dryRun) { + console.log(`[DRY-RUN] Would append Tangled mirror note to ${readmeFile} and push for ${repoName}`); + } else { + fs.appendFileSync( + readmePath, + ` Mirrored on Tangled: https://tangled.org/${atprotoDid}/${repoName} ` - ); - run(`git add ${readmeFile}`, repoDir); - run(`git commit -m "Add Tangled mirror reference to README"`, repoDir); - run(`git push origin main`, repoDir); - console.log(`[README] Updated for ${repoName}`); + ); + run(`git add ${readmeFile}`, repoDir); + run(`git commit -m "Add Tangled mirror reference to README"`, repoDir); + run(`git push origin main`, repoDir); + console.log(`[README] Updated for ${repoName}`); + } } } @@ -226,6 +244,9 @@ async function main() { if (FORCE_SYNC) { console.log("[MODE] Force sync enabled - will process all repos"); } + if (DRY_RUN) { + console.log("[MODE] Dry run enabled - no pushes, records, or README writes will be made"); + } console.log(`[CONFIG] Base directory: ${BASE_DIR}`); console.log(`[CONFIG] GitHub user: ${GITHUB_USER}`); console.log(`[CONFIG] ATProto DID: ${ATPROTO_DID}`); @@ -303,9 +324,9 @@ async function main() { console.log(`[EXISTS] ${repoName} already cloned`); } - await ensureTangledRemoteAndPush(repoDir, repoName, clone_url); - updateReadme(BASE_DIR, repoName, ATPROTO_DID); - const result = await ensureTangledRecord(agent, ATPROTO_DID, GITHUB_USER, repoName, description); + await ensureTangledRemoteAndPush(repoDir, repoName, clone_url, DRY_RUN); + updateReadme(BASE_DIR, repoName, ATPROTO_DID, DRY_RUN); + const result = await ensureTangledRecord(agent, ATPROTO_DID, GITHUB_USER, repoName, description, DRY_RUN); if (!result.existed) { syncedCount++; -- 2.51.2