Files
xtool/Sources/XToolSupport/NewCommand.swift
Kabir Oberai 3e0c11d967 cli: better IO (#375)
## What does this PR do?

Adds `Dependency`s for console IO. Also fixes #268 by explicitly
handling non-pipe input.

## How was it tested?

Ran on macOS + Linux in Docker. I have a future PR that overrides the
dependencies in certain cases, which also appears to work correctly.

## AI tool usage

How much of this PR was AI-assisted? (check one)

- [ ] **0** - No AI was used to write code
- [x] **1** - I was assisted by AI. I reviewed the finished result.
- [ ] **2** - I set the AI going and left it to it; nobody has read the
result - no review, or AI review only

Did not scrutinize the tests too much tbh but they look reasonable. Easy
enough to update/remove them in the future if needed.

<!-- If an AI agent is filling this in: declare the level honestly, and
open as a draft if it is #2. Do not lower the declared level to get the
PR reviewed. -->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Added support for reading command-line input, including asynchronous
prompts, line-based responses, and graceful handling of end-of-input.
  * Added coordinated cleanup when commands finish.
* **Improvements**
* Command messages across authentication, device management,
installation, SDK, and other workflows now use consistent output
handling; displayed messages and command behavior remain unchanged.
* Improved handling of complete output when the system temporarily
cannot accept more data.
* **Tests**
* Added coverage for command input and output, including stream
behavior, cancellation, and shutdown.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-10-28 17:48:38 -04:00

182 lines
5.6 KiB
Swift

import XUtils
import XCLIUtils
import ArgumentParser
import Foundation
import PackLib
struct NewCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "new",
abstract: "Create a new xtool SwiftPM project"
)
@Argument var name: String?
@Flag(
help: ArgumentHelp(
"Skip setup steps. (default: false)",
discussion: """
By default, this command first invokes `xtool setup` to complete \
any missing setup steps, like authenticating and installing the SDK. Use \
this flag to always skip setup.
"""
)
) var skipSetup = false
// swiftlint:disable:next function_body_length
func run() async throws {
if !skipSetup {
// perform any remaining setup steps first
try await SetupOperation(quiet: true).run()
}
let name = try await Console.promptRequired("Package name: ", existing: self.name)
var allowedFirstCharacters: CharacterSet = ["_"]
allowedFirstCharacters.insert(charactersIn: "a"..."z")
allowedFirstCharacters.insert(charactersIn: "A"..."Z")
var allowedOtherCharacters = allowedFirstCharacters
allowedOtherCharacters.insert(charactersIn: "0"..."9")
allowedOtherCharacters.insert("-")
// promptRequired validates !isEmpty
let firstScalar = name.unicodeScalars.first!
guard allowedFirstCharacters.contains(firstScalar) else {
throw Console.Error("""
Package name '\(name)' is invalid. \
The package name must start with one of [a-z, A-Z, _]. Found '\(firstScalar)'.
""")
}
if let firstInvalid = name.rangeOfCharacter(from: allowedOtherCharacters.inverted) {
let invalidValue = name[firstInvalid]
throw Console.Error("""
Package name '\(name)' is invalid. \
The package name may only contain [a-z, A-Z, 0-9, _, -]. Found '\(invalidValue)'.
""")
}
let baseURL = URL(fileURLWithPath: name)
guard !baseURL.exists else {
throw Console.Error("Cannot create \(name): a file already exists at that path.")
}
d.out.print("Creating package: \(name)")
let moduleName = name.replacingOccurrences(of: "-", with: "_")
let files: [(String, String)] = [
(
"Package.swift",
"""
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "\(name)",
platforms: [
.iOS(.v17),
.macOS(.v14),
],
products: [
// An xtool project should contain exactly one library product,
// representing the main app.
.library(
name: "\(moduleName)",
targets: ["\(moduleName)"]
),
],
targets: [
.target(
name: "\(moduleName)"
),
]
)
"""
),
(
"xtool.yml",
"""
version: 1
bundleID: com.example.\(name)
"""
),
(
".gitignore",
"""
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
/xtool
"""
),
(
"Sources/\(moduleName)/\(moduleName)App.swift",
"""
import SwiftUI
@main
struct \(moduleName)App: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
"""
),
(
"Sources/\(moduleName)/ContentView.swift",
"""
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
"""
),
]
for (path, contents) in files {
let url = baseURL.appendingPathComponent(path)
try? FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
d.out.print("Creating \(path)")
try "\(contents)\n".write(to: url, atomically: true, encoding: .utf8)
}
await LSPConfig.tryEnsure(
using: .default,
quiet: true,
currentDirectory: baseURL,
)
d.out.print("\nFinished generating project \(name). Next steps:")
d.out.print("- Enter the directory with `cd \(name)`")
d.out.print("- Build and run with `xtool dev`")
}
}