@sprid/cli 0.1.2
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/CHANGELOG.md +12 -0
- package/LICENSE +14 -0
- package/README-post.md +615 -0
- package/README.md +249 -0
- package/SECURITY.md +18 -0
- package/bin.mjs +12 -0
- package/package.json +47 -0
- package/src/args.mjs +134 -0
- package/src/browser.mjs +28 -0
- package/src/cli.mjs +257 -0
- package/src/commands/auth.mjs +192 -0
- package/src/commands/completion.mjs +90 -0
- package/src/commands/connect.mjs +586 -0
- package/src/commands/docs.mjs +53 -0
- package/src/commands/family.mjs +96 -0
- package/src/commands/init.mjs +137 -0
- package/src/commands/local-tools.mjs +31 -0
- package/src/commands/marketing-review.mjs +106 -0
- package/src/commands/mcp.mjs +53 -0
- package/src/commands/misc.mjs +94 -0
- package/src/commands/pinterest.mjs +120 -0
- package/src/commands/plan.mjs +230 -0
- package/src/commands/post.mjs +41 -0
- package/src/commands/reviews.mjs +149 -0
- package/src/commands/setup.mjs +220 -0
- package/src/commands/status.mjs +217 -0
- package/src/commands/studio.mjs +69 -0
- package/src/commands/update.mjs +69 -0
- package/src/creds.mjs +126 -0
- package/src/docs/commands.mjs +152 -0
- package/src/docs/guides.generated.mjs +1178 -0
- package/src/docs/help.mjs +77 -0
- package/src/docs/index.d.mts +18 -0
- package/src/docs/index.mjs +45 -0
- package/src/docs/queries.d.mts +11 -0
- package/src/docs/queries.mjs +77 -0
- package/src/endpoint.mjs +17 -0
- package/src/evidence.mjs +15 -0
- package/src/format.mjs +71 -0
- package/src/http.mjs +117 -0
- package/src/pending.mjs +27 -0
- package/src/post/cli.mjs +2285 -0
- package/src/post/json-worker.mjs +12 -0
- package/src/post/preview-server.mjs +58 -0
- package/src/post/preview.mjs +660 -0
- package/src/post/recipes/screen.mjs +290 -0
- package/src/post/recipes/stills.mjs +146 -0
- package/src/post/rules/platform-rules.d.mts +27 -0
- package/src/post/rules/platform-rules.mjs +164 -0
- package/src/post/screen/captions.mjs +131 -0
- package/src/post/screen/compose.mjs +284 -0
- package/src/post/screen/input.mjs +163 -0
- package/src/post/screen/sim.mjs +443 -0
- package/src/post/screen/simkit.swift +328 -0
- package/src/profiles.mjs +61 -0
- package/src/release.ts +2 -0
- package/src/screenshots.ts +1 -0
- package/src/updates.mjs +152 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// simkit - the two things a screen-recorded reel needs that no CLI on this
|
|
2
|
+
// machine provides: a human-shaped touch, and a rasterised caption.
|
|
3
|
+
//
|
|
4
|
+
// **Why CGEvent and not idb/Maestro.** Both are real dependencies (a Python
|
|
5
|
+
// companion, a JVM) and both send a LINEAR interpolation, which is the one
|
|
6
|
+
// thing that makes a scripted scroll read as scripted: iOS computes momentum
|
|
7
|
+
// from the velocity at the instant the finger lifts, so a gesture that
|
|
8
|
+
// decelerates into its endpoint produces a dead scroll with no inertia. A
|
|
9
|
+
// posted CGEvent stream costs nothing to install and lets the velocity curve be
|
|
10
|
+
// the thing we control.
|
|
11
|
+
//
|
|
12
|
+
// Synthetic input requires Accessibility permission for the automation host.
|
|
13
|
+
// Verify it with a tap before recording.
|
|
14
|
+
//
|
|
15
|
+
// Build: swiftc -O -o simkit simkit.swift (cached by the caller)
|
|
16
|
+
|
|
17
|
+
import Foundation
|
|
18
|
+
import CoreGraphics
|
|
19
|
+
import AppKit
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// args
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
let ARGV = CommandLine.arguments
|
|
26
|
+
func opt(_ n: String) -> String? {
|
|
27
|
+
guard let i = ARGV.firstIndex(of: "--" + n), i + 1 < ARGV.count else { return nil }
|
|
28
|
+
return ARGV[i + 1]
|
|
29
|
+
}
|
|
30
|
+
func num(_ n: String, _ d: Double) -> Double { Double(opt(n) ?? "") ?? d }
|
|
31
|
+
func has(_ n: String) -> Bool { ARGV.contains("--" + n) }
|
|
32
|
+
func all(_ n: String) -> [String] {
|
|
33
|
+
var out: [String] = []
|
|
34
|
+
var i = 0
|
|
35
|
+
while i < ARGV.count { if ARGV[i] == "--" + n, i + 1 < ARGV.count { out.append(ARGV[i+1]); i += 1 }; i += 1 }
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
func die(_ m: String, _ code: Int32 = 2) -> Never {
|
|
39
|
+
FileHandle.standardError.write((m + "\n").data(using: .utf8)!)
|
|
40
|
+
exit(code)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// the window, and where the device screen sits inside it
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
struct Rect { var x: Double; var y: Double; var w: Double; var h: Double }
|
|
48
|
+
|
|
49
|
+
func simulatorWindow() -> Rect? {
|
|
50
|
+
guard let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return nil }
|
|
51
|
+
var best: Rect?
|
|
52
|
+
for w in list {
|
|
53
|
+
guard (w[kCGWindowOwnerName as String] as? String) == "Simulator",
|
|
54
|
+
let b = w[kCGWindowBounds as String] as? [String: Any] else { continue }
|
|
55
|
+
let r = Rect(x: b["X"] as? Double ?? 0, y: b["Y"] as? Double ?? 0,
|
|
56
|
+
w: b["Width"] as? Double ?? 0, h: b["Height"] as? Double ?? 0)
|
|
57
|
+
if r.w < 120 || r.h < 120 { continue }
|
|
58
|
+
if best == nil || r.w * r.h > best!.w * best!.h { best = r }
|
|
59
|
+
}
|
|
60
|
+
return best
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Where the device screen sits inside the window.
|
|
64
|
+
///
|
|
65
|
+
/// **Measured, not assumed** (2026-09-05). Dragging a known fraction of the
|
|
66
|
+
/// screen and reading the pixel shift back out of a device screenshot at three
|
|
67
|
+
/// distances solves two unknowns at once: the screen's height in window points,
|
|
68
|
+
/// and UIScrollView's pan slop. 0.05/0.10/0.15 of height moved a list
|
|
69
|
+
/// 109/247/382 device px, whose differences give **910 points of screen** in a
|
|
70
|
+
/// 931-point window - the device fills everything below a 21-point title bar -
|
|
71
|
+
/// and **9.2 points of slop** before a scroll starts tracking at all.
|
|
72
|
+
///
|
|
73
|
+
/// The first pass here read the single-distance version as 874 points and
|
|
74
|
+
/// "confirmed" a centred device with a drawn bezel. It was the slop: a constant
|
|
75
|
+
/// loss fits any scale if you only measure one distance. Two would have been
|
|
76
|
+
/// enough; three is what makes it obvious.
|
|
77
|
+
///
|
|
78
|
+
/// The caller owns this model - it passes `--rect` from its own calibration -
|
|
79
|
+
/// and this is only the default.
|
|
80
|
+
func defaultDeviceRect(_ win: Rect, aspect: Double, titleBar: Double) -> Rect {
|
|
81
|
+
let inner = Rect(x: win.x, y: win.y + titleBar, w: win.w, h: win.h - titleBar)
|
|
82
|
+
if inner.w / inner.h > aspect {
|
|
83
|
+
let w = inner.h * aspect
|
|
84
|
+
return Rect(x: inner.x + (inner.w - w) / 2, y: inner.y, w: w, h: inner.h)
|
|
85
|
+
}
|
|
86
|
+
let h = inner.w / aspect
|
|
87
|
+
return Rect(x: inner.x, y: inner.y + (inner.h - h) / 2, w: inner.w, h: h)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// posting
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
let src = CGEventSource(stateID: .hidSystemState)
|
|
95
|
+
func post(_ type: CGEventType, _ p: CGPoint) {
|
|
96
|
+
CGEvent(mouseEventSource: src, mouseType: type, mouseCursorPosition: p, mouseButton: .left)?
|
|
97
|
+
.post(tap: .cghidEventTap)
|
|
98
|
+
}
|
|
99
|
+
func sleepMs(_ ms: Double) { if ms > 0 { usleep(useconds_t(ms * 1000)) } }
|
|
100
|
+
|
|
101
|
+
var rng = SystemRandomNumberGenerator()
|
|
102
|
+
func jit(_ a: Double) -> Double { a <= 0 ? 0 : Double.random(in: -a...a, using: &rng) }
|
|
103
|
+
|
|
104
|
+
/// Ease-in-out: a deliberate drag that arrives and stops. For pulling a sheet
|
|
105
|
+
/// to a position, not for scrolling.
|
|
106
|
+
func easeInOut(_ t: Double) -> Double { t < 0.5 ? 2*t*t : 1 - pow(-2*t + 2, 2)/2 }
|
|
107
|
+
/// **A flick still moving at the moment of release.** This is the whole reason
|
|
108
|
+
/// this file exists: iOS reads the last few milliseconds of travel as the
|
|
109
|
+
/// throw velocity, so a curve that decelerates hands it zero and the list stops
|
|
110
|
+
/// dead under the finger.
|
|
111
|
+
func easeInHold(_ t: Double) -> Double { pow(t, 1.55) }
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// gestures
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
let HZ = 120.0
|
|
118
|
+
|
|
119
|
+
func tap(_ p: CGPoint, holdMs: Double) {
|
|
120
|
+
post(.mouseMoved, CGPoint(x: p.x + jit(7), y: p.y + jit(7))) // the approach
|
|
121
|
+
sleepMs(Double.random(in: 45...90, using: &rng))
|
|
122
|
+
post(.mouseMoved, p)
|
|
123
|
+
sleepMs(Double.random(in: 20...45, using: &rng))
|
|
124
|
+
post(.leftMouseDown, p)
|
|
125
|
+
let steps = max(1, Int(holdMs / (1000/HZ)))
|
|
126
|
+
for _ in 0..<steps { // a finger is never still
|
|
127
|
+
post(.leftMouseDragged, CGPoint(x: p.x + jit(0.9), y: p.y + jit(0.9)))
|
|
128
|
+
sleepMs(1000/HZ)
|
|
129
|
+
}
|
|
130
|
+
post(.leftMouseUp, p)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func swipe(_ a: CGPoint, _ b: CGPoint, durationMs: Double, flick: Bool, bow: Double, settleMs: Double) {
|
|
134
|
+
post(.mouseMoved, CGPoint(x: a.x + jit(10), y: a.y + jit(10)))
|
|
135
|
+
sleepMs(Double.random(in: 60...120, using: &rng))
|
|
136
|
+
post(.mouseMoved, a)
|
|
137
|
+
sleepMs(Double.random(in: 25...55, using: &rng))
|
|
138
|
+
post(.leftMouseDown, a)
|
|
139
|
+
sleepMs(Double.random(in: 12...28, using: &rng)) // press, then travel
|
|
140
|
+
|
|
141
|
+
let dx = b.x - a.x, dy = b.y - a.y
|
|
142
|
+
let dist = (dx*dx + dy*dy).squareRoot()
|
|
143
|
+
let nx = dist > 0 ? -dy/dist : 0, ny = dist > 0 ? dx/dist : 0
|
|
144
|
+
let bowPx = bow * dist * (Bool.random(using: &rng) ? 1 : -1) // nobody drags a straight line
|
|
145
|
+
|
|
146
|
+
let steps = max(4, Int(durationMs / (1000/HZ)))
|
|
147
|
+
for i in 1...steps {
|
|
148
|
+
let t = Double(i) / Double(steps)
|
|
149
|
+
let e = flick ? easeInHold(t) : easeInOut(t)
|
|
150
|
+
let arc = sin(e * .pi) * bowPx
|
|
151
|
+
post(.leftMouseDragged, CGPoint(x: a.x + dx*e + nx*arc + jit(0.7),
|
|
152
|
+
y: a.y + dy*e + ny*arc + jit(0.7)))
|
|
153
|
+
sleepMs(1000/HZ)
|
|
154
|
+
}
|
|
155
|
+
if !flick { sleepMs(settleMs) }
|
|
156
|
+
post(.leftMouseUp, b)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// text: a caption card, rasterised
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
func registerFont(_ path: String) -> String? {
|
|
164
|
+
let url = URL(fileURLWithPath: path)
|
|
165
|
+
CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
|
|
166
|
+
guard let d = CGDataProvider(url: url as CFURL), let cg = CGFont(d) else { return nil }
|
|
167
|
+
return cg.postScriptName as String?
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func hexColor(_ hex: String, _ alpha: Double = 1) -> NSColor {
|
|
171
|
+
var s = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex
|
|
172
|
+
if s.count == 3 { s = s.map { "\($0)\($0)" }.joined() }
|
|
173
|
+
let v = UInt32(s, radix: 16) ?? 0xFFFFFF
|
|
174
|
+
return NSColor(srgbRed: Double((v >> 16) & 255)/255, green: Double((v >> 8) & 255)/255,
|
|
175
|
+
blue: Double(v & 255)/255, alpha: alpha)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func renderText() {
|
|
179
|
+
let lines = all("line")
|
|
180
|
+
guard !lines.isEmpty else { die("text needs at least one --line") }
|
|
181
|
+
guard let outPath = opt("out") else { die("text needs --out") }
|
|
182
|
+
let width = num("w", 980)
|
|
183
|
+
let size = num("size", 78)
|
|
184
|
+
let tracking = num("tracking", -1)
|
|
185
|
+
let lineGap = num("linegap", 10)
|
|
186
|
+
let strokeW = num("stroke", 13)
|
|
187
|
+
let padX = num("padx", 34), padY = num("pady", 20)
|
|
188
|
+
let radius = num("radius", 22)
|
|
189
|
+
let pillAlpha = num("pill", 0)
|
|
190
|
+
|
|
191
|
+
var font: NSFont?
|
|
192
|
+
if let f = opt("font-file"), let ps = registerFont(f) { font = NSFont(name: ps, size: size) }
|
|
193
|
+
if font == nil, let n = opt("font") { font = NSFont(name: n, size: size) }
|
|
194
|
+
let f = font ?? NSFont.systemFont(ofSize: size, weight: .heavy)
|
|
195
|
+
|
|
196
|
+
let para = NSMutableParagraphStyle()
|
|
197
|
+
para.alignment = .center
|
|
198
|
+
para.lineSpacing = lineGap
|
|
199
|
+
para.lineBreakMode = .byWordWrapping
|
|
200
|
+
|
|
201
|
+
let shadow = NSShadow()
|
|
202
|
+
shadow.shadowColor = NSColor(white: 0, alpha: 0.45)
|
|
203
|
+
shadow.shadowBlurRadius = num("shadow", 12)
|
|
204
|
+
shadow.shadowOffset = NSSize(width: 0, height: -num("shadowy", 4))
|
|
205
|
+
|
|
206
|
+
var attrs: [NSAttributedString.Key: Any] = [
|
|
207
|
+
.font: f,
|
|
208
|
+
.foregroundColor: hexColor(opt("color") ?? "ffffff"),
|
|
209
|
+
.paragraphStyle: para,
|
|
210
|
+
.kern: tracking,
|
|
211
|
+
.shadow: shadow,
|
|
212
|
+
]
|
|
213
|
+
// A NEGATIVE stroke width is fill-and-stroke; a positive one is stroke only
|
|
214
|
+
// and renders hollow text, which is the classic way to lose an hour here.
|
|
215
|
+
if strokeW > 0 {
|
|
216
|
+
attrs[.strokeWidth] = -strokeW
|
|
217
|
+
attrs[.strokeColor] = hexColor(opt("stroke-color") ?? "000000")
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let text = NSAttributedString(string: lines.joined(separator: "\n"), attributes: attrs)
|
|
221
|
+
let textW = width - padX * 2
|
|
222
|
+
let bounds = text.boundingRect(with: NSSize(width: textW, height: 4000),
|
|
223
|
+
options: [.usesLineFragmentOrigin, .usesFontLeading])
|
|
224
|
+
let h = ceil(bounds.height) + padY * 2 + strokeW
|
|
225
|
+
let w = width
|
|
226
|
+
|
|
227
|
+
guard let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(w), pixelsHigh: Int(h),
|
|
228
|
+
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
|
229
|
+
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)
|
|
230
|
+
else { die("could not allocate a bitmap") }
|
|
231
|
+
NSGraphicsContext.saveGraphicsState()
|
|
232
|
+
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
|
233
|
+
NSColor.clear.setFill()
|
|
234
|
+
NSRect(x: 0, y: 0, width: w, height: h).fill()
|
|
235
|
+
|
|
236
|
+
if pillAlpha > 0 {
|
|
237
|
+
let pillW = min(w, ceil(bounds.width) + padX * 2)
|
|
238
|
+
let r = NSRect(x: (w - pillW) / 2, y: 0, width: pillW, height: h)
|
|
239
|
+
hexColor(opt("pill-color") ?? "000000", pillAlpha).setFill()
|
|
240
|
+
NSBezierPath(roundedRect: r, xRadius: radius, yRadius: radius).fill()
|
|
241
|
+
}
|
|
242
|
+
text.draw(with: NSRect(x: padX, y: padY, width: textW, height: h - padY * 2),
|
|
243
|
+
options: [.usesLineFragmentOrigin, .usesFontLeading])
|
|
244
|
+
|
|
245
|
+
NSGraphicsContext.restoreGraphicsState()
|
|
246
|
+
guard let png = rep.representation(using: .png, properties: [:]) else { die("png encode failed") }
|
|
247
|
+
try? png.write(to: URL(fileURLWithPath: outPath))
|
|
248
|
+
print("\(Int(w)) \(Int(h))")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/// The touch indicator. A screen recording has no finger in it, and without one
|
|
252
|
+
/// the viewer cannot tell a tap from the app moving on its own.
|
|
253
|
+
func renderDot() {
|
|
254
|
+
guard let outPath = opt("out") else { die("dot needs --out") }
|
|
255
|
+
let r = num("r", 54)
|
|
256
|
+
let s = Int(r * 2)
|
|
257
|
+
guard let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: s, pixelsHigh: s,
|
|
258
|
+
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
|
259
|
+
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)
|
|
260
|
+
else { die("could not allocate a bitmap") }
|
|
261
|
+
NSGraphicsContext.saveGraphicsState()
|
|
262
|
+
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
|
263
|
+
NSColor.clear.setFill()
|
|
264
|
+
NSRect(x: 0, y: 0, width: Double(s), height: Double(s)).fill()
|
|
265
|
+
// Soft, not a hard puck: iOS' own touch indicator is a low-contrast disc and
|
|
266
|
+
// a hard white circle reads as a cursor.
|
|
267
|
+
let steps = 26
|
|
268
|
+
for i in stride(from: steps, through: 1, by: -1) {
|
|
269
|
+
let t = Double(i) / Double(steps)
|
|
270
|
+
let rr = r * t
|
|
271
|
+
NSColor(white: 1, alpha: 0.055 * pow(1 - t, 0.6)).setFill()
|
|
272
|
+
NSBezierPath(ovalIn: NSRect(x: r - rr, y: r - rr, width: rr * 2, height: rr * 2)).fill()
|
|
273
|
+
}
|
|
274
|
+
NSColor(white: 1, alpha: 0.30).setFill()
|
|
275
|
+
NSBezierPath(ovalIn: NSRect(x: r * 0.42, y: r * 0.42, width: r * 1.16, height: r * 1.16)).fill()
|
|
276
|
+
NSColor(white: 1, alpha: 0.34).setStroke()
|
|
277
|
+
let ring = NSBezierPath(ovalIn: NSRect(x: r * 0.42, y: r * 0.42, width: r * 1.16, height: r * 1.16))
|
|
278
|
+
ring.lineWidth = 2.5
|
|
279
|
+
ring.stroke()
|
|
280
|
+
NSGraphicsContext.restoreGraphicsState()
|
|
281
|
+
guard let png = rep.representation(using: .png, properties: [:]) else { die("png encode failed") }
|
|
282
|
+
try? png.write(to: URL(fileURLWithPath: outPath))
|
|
283
|
+
print("\(s) \(s)")
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// main
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
guard ARGV.count > 1 else { die("usage: simkit window|tap|swipe|park|text|dot [options]") }
|
|
291
|
+
let cmd = ARGV[1]
|
|
292
|
+
|
|
293
|
+
if cmd == "text" { renderText(); exit(0) }
|
|
294
|
+
if cmd == "dot" { renderDot(); exit(0) }
|
|
295
|
+
|
|
296
|
+
guard let win = simulatorWindow() else { die("No Simulator window on screen. Is Simulator.app open?", 3) }
|
|
297
|
+
let dev: Rect = {
|
|
298
|
+
if let r = opt("rect") {
|
|
299
|
+
let p = r.split(separator: ",").map { Double($0) ?? 0 }
|
|
300
|
+
if p.count == 4 { return Rect(x: p[0], y: p[1], w: p[2], h: p[3]) }
|
|
301
|
+
die("--rect wants x,y,w,h in screen points")
|
|
302
|
+
}
|
|
303
|
+
return defaultDeviceRect(win, aspect: num("aspect", 402.0/874.0), titleBar: num("titlebar", 21))
|
|
304
|
+
}()
|
|
305
|
+
func pt(_ nx: Double, _ ny: Double) -> CGPoint { CGPoint(x: dev.x + nx * dev.w, y: dev.y + ny * dev.h) }
|
|
306
|
+
|
|
307
|
+
if !has("no-activate") {
|
|
308
|
+
for app in NSWorkspace.shared.runningApplications where app.localizedName == "Simulator" {
|
|
309
|
+
app.activate(options: [])
|
|
310
|
+
}
|
|
311
|
+
sleepMs(num("activate-wait", 220))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
switch cmd {
|
|
315
|
+
case "window":
|
|
316
|
+
print("window \(win.x) \(win.y) \(win.w) \(win.h)")
|
|
317
|
+
print("device \(dev.x) \(dev.y) \(dev.w) \(dev.h)")
|
|
318
|
+
case "tap":
|
|
319
|
+
tap(pt(num("x", 0.5), num("y", 0.5)), holdMs: num("hold", Double.random(in: 55...100, using: &rng)))
|
|
320
|
+
case "swipe":
|
|
321
|
+
swipe(pt(num("x1", 0.5), num("y1", 0.72)), pt(num("x2", 0.5), num("y2", 0.28)),
|
|
322
|
+
durationMs: num("duration", 260), flick: !has("precise"),
|
|
323
|
+
bow: num("bow", 0.025), settleMs: num("settle", 90))
|
|
324
|
+
case "park":
|
|
325
|
+
post(.mouseMoved, CGPoint(x: win.x + win.w + 60, y: win.y + 20))
|
|
326
|
+
default:
|
|
327
|
+
die("unknown command \(cmd)")
|
|
328
|
+
}
|
package/src/profiles.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// App Profiles over REST, and the three ways to say which one:
|
|
2
|
+
// `--app <slug>`, the only one there is, or the slug in ./.sprid/app.json.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { UsageError } from "./args.mjs";
|
|
7
|
+
|
|
8
|
+
export function readLocalApp(cwd) {
|
|
9
|
+
const p = resolve(cwd, ".sprid", "app.json");
|
|
10
|
+
if (!existsSync(p)) return null;
|
|
11
|
+
try {
|
|
12
|
+
return { path: p, data: JSON.parse(readFileSync(p, "utf8")) };
|
|
13
|
+
} catch (e) {
|
|
14
|
+
throw new UsageError(`${p} is not valid JSON: ${e.message}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function listProfiles(ctx, workspaceId) {
|
|
19
|
+
const res = await ctx.api().get(`/api/app-profiles?workspaceId=${workspaceId}`);
|
|
20
|
+
return res?.profiles ?? [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function resolveProfile(ctx, workspaceId, { required = true } = {}) {
|
|
24
|
+
const profiles = await listProfiles(ctx, workspaceId);
|
|
25
|
+
const want = ctx.flags.app || readLocalApp(ctx.cwd)?.data?.appId || readLocalApp(ctx.cwd)?.data?.slug || null;
|
|
26
|
+
if (want) {
|
|
27
|
+
const hit = profiles.find((p) => p.slug === want || String(p.id) === String(want));
|
|
28
|
+
if (!hit) {
|
|
29
|
+
throw new UsageError(
|
|
30
|
+
`No App Profile "${want}" in this workspace${profiles.length ? ` (have: ${profiles.map((p) => p.slug).join(", ")})` : ""}. Run \`sprid init\` first.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return hit;
|
|
34
|
+
}
|
|
35
|
+
if (profiles.length === 1) return profiles[0];
|
|
36
|
+
if (!required) return null;
|
|
37
|
+
if (profiles.length === 0) throw new UsageError("No App Profile yet. Run `sprid init` in the app's repo.");
|
|
38
|
+
throw new UsageError(`Which app? Pass --app <slug> (${profiles.map((p) => p.slug).join(", ")}), or run from the repo with .sprid/app.json.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function patchProfile(ctx, workspaceId, id, body) {
|
|
42
|
+
return ctx.api().patch(`/api/app-profiles/${id}?workspaceId=${workspaceId}`, body);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function createProfile(ctx, workspaceId, body) {
|
|
46
|
+
return ctx.api().post(`/api/app-profiles`, { workspaceId, ...body });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readKeyFile(path, cwd) {
|
|
50
|
+
const p = resolve(cwd, expandHome(path));
|
|
51
|
+
if (!existsSync(p)) throw new UsageError(`Key file not found: ${p}`);
|
|
52
|
+
const text = readFileSync(p, "utf8");
|
|
53
|
+
if (!text.trim()) throw new UsageError(`Key file is empty: ${p}`);
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function expandHome(p) {
|
|
58
|
+
if (p === "~") return process.env.HOME || p;
|
|
59
|
+
if (p.startsWith("~/")) return join(process.env.HOME || "", p.slice(2));
|
|
60
|
+
return p;
|
|
61
|
+
}
|
package/src/release.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { AsoConfig, ReviewConfig } from '@sprid/shots/types';
|
package/src/updates.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
12
|
+
// The unscoped name `sprid` is refused by npm's similarity filter, so the
|
|
13
|
+
// package is scoped while the command stays `sprid`. The registry path needs
|
|
14
|
+
// the slash encoded; every other reference is the literal package name.
|
|
15
|
+
const PACKAGE = '@sprid/cli';
|
|
16
|
+
const PACKAGE_PATH = '@sprid%2fcli';
|
|
17
|
+
const DAY = 86_400_000;
|
|
18
|
+
const read = path => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
19
|
+
const real = path => { try { return realpathSync(path); } catch { return null; } };
|
|
20
|
+
export const stableVersion = value => typeof value === 'string' && /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/.test(value);
|
|
21
|
+
export function compareVersions(a, b) {
|
|
22
|
+
if (!stableVersion(a) || !stableVersion(b)) throw new Error('Expected a stable x.y.z version.');
|
|
23
|
+
const x = a.split('.').map(Number), y = b.split('.').map(Number);
|
|
24
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return Math.sign(x[i] - y[i]);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
export function compatibleUpdate(current, next) {
|
|
28
|
+
if (!stableVersion(current) || !stableVersion(next) || compareVersions(next, current) <= 0) return false;
|
|
29
|
+
const [a, b] = current.split('.').map(Number), [x, y] = next.split('.').map(Number);
|
|
30
|
+
return a === x && (a > 0 || (b === y && b > 0));
|
|
31
|
+
}
|
|
32
|
+
function atomicJson(path, data) {
|
|
33
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
34
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
35
|
+
try { writeFileSync(tmp, JSON.stringify(data) + '\n', { mode: 0o600, flag: 'wx' }); renameSync(tmp, path); }
|
|
36
|
+
finally { rmSync(tmp, { force: true }); }
|
|
37
|
+
}
|
|
38
|
+
export function stateDirectory(env = process.env) { return join(env.HOME || homedir(), '.sprid', 'updates'); }
|
|
39
|
+
|
|
40
|
+
/** Registry failure is data, never a reason to block unrelated work. No auth is sent. */
|
|
41
|
+
export async function checkVersion({ current, env = process.env, fetchImpl = globalThis.fetch, now = Date.now(), force = false, offline = false } = {}) {
|
|
42
|
+
const path = join(stateDirectory(env), 'latest.json');
|
|
43
|
+
const cached = read(path);
|
|
44
|
+
const fresh = cached && Number.isFinite(cached.checkedAt) && now >= cached.checkedAt && now - cached.checkedAt < DAY;
|
|
45
|
+
let record = cached;
|
|
46
|
+
if (!offline && (force || !fresh)) {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetchImpl(`${REGISTRY}/${PACKAGE_PATH}/latest`, { redirect: 'error', signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });
|
|
49
|
+
if (!res.ok) { await res.body?.cancel(); throw new Error('registry unavailable'); }
|
|
50
|
+
const reader = res.body.getReader(); let size = 0; const chunks = [];
|
|
51
|
+
try {
|
|
52
|
+
while (true) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > 131072) throw new Error('registry response too large'); chunks.push(Buffer.from(value)); }
|
|
53
|
+
} finally { await reader.cancel().catch(() => {}); }
|
|
54
|
+
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
55
|
+
if (data.name !== PACKAGE || !stableVersion(data.version)) throw new Error('invalid registry version');
|
|
56
|
+
record = { checkedAt: now, version: data.version, available: true };
|
|
57
|
+
} catch { record = { checkedAt: now, version: stableVersion(cached?.version) ? cached.version : null, available: false }; }
|
|
58
|
+
try { atomicJson(path, record); } catch { /* A read-only home must not prevent checking. */ }
|
|
59
|
+
}
|
|
60
|
+
const recommended = stableVersion(record?.version) ? record.version : null;
|
|
61
|
+
const verified = !offline && record?.available === true && Number.isFinite(record.checkedAt) && now >= record.checkedAt && now - record.checkedAt < DAY;
|
|
62
|
+
const updateAvailable = Boolean(recommended && stableVersion(current) && compareVersions(recommended, current) > 0);
|
|
63
|
+
return { current, recommended, check: verified ? 'verified' : 'unavailable', cached: Boolean(cached) && record === cached, checkedAt: record?.checkedAt ?? null,
|
|
64
|
+
updateAvailable, compatible: updateAvailable && compatibleUpdate(current, recommended),
|
|
65
|
+
requiresApproval: updateAvailable && !compatibleUpdate(current, recommended),
|
|
66
|
+
source: `${REGISTRY}/${PACKAGE_PATH}/latest` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function execute(command, args, options) {
|
|
70
|
+
return exec(command, args, { timeout: 120000, maxBuffer: 4 * 1024 * 1024, ...options });
|
|
71
|
+
}
|
|
72
|
+
export const runProcess = execute;
|
|
73
|
+
|
|
74
|
+
/** Identify the running package, never a similarly named package in cwd. */
|
|
75
|
+
export async function detectInstallation({ packageRoot = PACKAGE_ROOT, env = process.env, runner = execute, platform = process.platform } = {}) {
|
|
76
|
+
const root = real(packageRoot);
|
|
77
|
+
const unsupported = reason => ({ kind: 'unsupported', packageRoot: root, reason });
|
|
78
|
+
if (!root || read(join(root, 'package.json'))?.name !== PACKAGE) return unsupported('Cannot identify this Sprid installation.');
|
|
79
|
+
if (platform === 'win32') return unsupported('On Windows, update through the package manager that installed Sprid.');
|
|
80
|
+
// A checkout or npm link must never be overwritten by the updater.
|
|
81
|
+
if (!root.split(/[\\/]/).includes('node_modules')) return unsupported('Source checkout or linked installation. Update its source instead.');
|
|
82
|
+
for (let dir = dirname(root); dirname(dir) !== dir; dir = dirname(dir)) {
|
|
83
|
+
if (dir.split(/[\\/]/).includes('_npx')) return unsupported('Temporary npx installation. Start a new npx invocation with the desired version.');
|
|
84
|
+
const pkg = read(join(dir, 'package.json'));
|
|
85
|
+
const field = ['dependencies', 'devDependencies', 'optionalDependencies'].find(key => typeof pkg?.[key]?.[PACKAGE] === 'string');
|
|
86
|
+
if (!field || real(join(dir, 'node_modules', '@sprid', 'cli')) !== root) continue;
|
|
87
|
+
const declared = pkg[field][PACKAGE];
|
|
88
|
+
if (!/^[~^]?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(declared)) return unsupported('Sprid is linked or uses a custom package source. Update that declaration manually.');
|
|
89
|
+
let manager = null, lockRoot = dir;
|
|
90
|
+
for (let cursor = dir; dirname(cursor) !== cursor; cursor = dirname(cursor)) {
|
|
91
|
+
const manifest = read(join(cursor, 'package.json'));
|
|
92
|
+
const declaredManager = manifest?.packageManager?.match(/^(npm|pnpm|yarn|bun)@/)?.[1];
|
|
93
|
+
const locks = [['npm', 'package-lock.json'], ['npm', 'npm-shrinkwrap.json'], ['pnpm', 'pnpm-lock.yaml'], ['yarn', 'yarn.lock'], ['bun', 'bun.lock'], ['bun', 'bun.lockb']].filter(([, file]) => existsSync(join(cursor, file))).map(([name]) => name);
|
|
94
|
+
const unique = [...new Set(locks)];
|
|
95
|
+
if (unique.length > 1 || (declaredManager && unique.length && !unique.includes(declaredManager))) return unsupported('Conflicting package managers or lockfiles. Resolve them before updating.');
|
|
96
|
+
if (declaredManager || unique.length) { manager = declaredManager || unique[0]; lockRoot = cursor; break; }
|
|
97
|
+
}
|
|
98
|
+
if (!manager) return unsupported('No packageManager or lockfile identifies the project package manager.');
|
|
99
|
+
return { kind: 'local', packageRoot: root, projectRoot: dir, lockRoot, manager, field };
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const options = { cwd: env.HOME || homedir(), env, timeout: 3000 };
|
|
103
|
+
const { stdout: globalRoot } = await runner('npm', ['root', '--global'], options);
|
|
104
|
+
const { stdout: prefix } = await runner('npm', ['prefix', '--global'], options);
|
|
105
|
+
if (real(join(globalRoot.trim(), '@sprid', 'cli')) === root && real(prefix.trim())) return { kind: 'global', manager: 'npm', packageRoot: root, prefix: real(prefix.trim()) };
|
|
106
|
+
} catch { /* No usable npm on this PATH. */ }
|
|
107
|
+
return unsupported('Installation is not an identified npm global or project dependency. Update with its original package manager.');
|
|
108
|
+
}
|
|
109
|
+
function keyFor(installation) { return createHash('sha256').update(installation.projectRoot || installation.packageRoot || '').digest('hex'); }
|
|
110
|
+
export function autoEnabled(installation, env = process.env) {
|
|
111
|
+
return read(join(stateDirectory(env), `${keyFor(installation)}.json`))?.auto === true;
|
|
112
|
+
}
|
|
113
|
+
export function setAuto(installation, enabled, env = process.env) {
|
|
114
|
+
if (installation.kind === 'unsupported') throw new Error(installation.reason);
|
|
115
|
+
atomicJson(join(stateDirectory(env), `${keyFor(installation)}.json`), { auto: enabled });
|
|
116
|
+
}
|
|
117
|
+
export function installationCommand(installation, version) {
|
|
118
|
+
if (installation.kind === 'unsupported' || !stableVersion(version)) return null;
|
|
119
|
+
const target = `${PACKAGE}@${version}`;
|
|
120
|
+
if (installation.kind === 'global') return { command: 'npm', args: ['install', '--global', '--prefix', installation.prefix, '--ignore-scripts', `--registry=${REGISTRY}`, target] };
|
|
121
|
+
const dev = installation.field === 'devDependencies', optional = installation.field === 'optionalDependencies';
|
|
122
|
+
if (installation.manager === 'npm') return { command: 'npm', args: ['install', '--save-exact', '--ignore-scripts', `--registry=${REGISTRY}`, ...(dev ? ['--save-dev'] : optional ? ['--save-optional'] : ['--save-prod']), target] };
|
|
123
|
+
if (installation.manager === 'pnpm') return { command: 'pnpm', args: ['add', '--save-exact', '--ignore-scripts', '--ignore-pnpmfile', `--registry=${REGISTRY}`, ...(installation.projectRoot === installation.lockRoot && existsSync(join(installation.lockRoot, 'pnpm-workspace.yaml')) ? ['--workspace-root'] : []), ...(dev ? ['--save-dev'] : optional ? ['--save-optional'] : ['--save-prod']), target] };
|
|
124
|
+
if (installation.manager === 'bun') return { command: 'bun', args: ['add', '--exact', '--ignore-scripts', `--registry=${REGISTRY}`, ...(dev ? ['--dev'] : optional ? ['--optional'] : []), target] };
|
|
125
|
+
// Yarn's build-suppression flag depends on its major version, resolved before execution.
|
|
126
|
+
return { command: 'yarn', args: ['add', '--exact', ...(dev ? ['--dev'] : optional ? ['--optional'] : []), target] };
|
|
127
|
+
}
|
|
128
|
+
export async function performUpdate(installation, version, { env = process.env, runner = execute } = {}) {
|
|
129
|
+
const plan = installationCommand(installation, version);
|
|
130
|
+
if (!plan) throw new Error(installation.reason || 'Invalid update target.');
|
|
131
|
+
const state = stateDirectory(env), lock = join(state, `${keyFor(installation)}.lock`);
|
|
132
|
+
mkdirSync(state, { recursive: true, mode: 0o700 });
|
|
133
|
+
try { mkdirSync(lock, { mode: 0o700 }); } catch { throw new Error(`Another update may be running. If it was interrupted, remove ${lock} before retrying.`); }
|
|
134
|
+
try {
|
|
135
|
+
const options = { cwd: installation.projectRoot || env.HOME || homedir(), env: { ...env, npm_config_ignore_scripts: 'true' } };
|
|
136
|
+
if (plan.command === 'yarn') {
|
|
137
|
+
const { stdout } = await runner('yarn', ['--version'], { ...options, timeout: 3000 });
|
|
138
|
+
if (!stableVersion(stdout.trim())) throw new Error('Cannot identify Yarn version.');
|
|
139
|
+
const major = Number(stdout.trim().split('.')[0]);
|
|
140
|
+
plan.args.push(major >= 4 ? '--mode=skip-build' : major >= 2 ? '--mode=skip-builds' : '--ignore-scripts');
|
|
141
|
+
}
|
|
142
|
+
try { await runner(plan.command, plan.args, options); }
|
|
143
|
+
catch { throw new Error('Package manager update failed. Installation or lockfiles may have changed; inspect them before retrying.'); }
|
|
144
|
+
// Resolve again after pnpm changes a node_modules symlink to a new store path.
|
|
145
|
+
const root = installation.kind === 'local' ? real(join(installation.projectRoot, 'node_modules', '@sprid', 'cli')) : installation.packageRoot;
|
|
146
|
+
const installed = root && read(join(root, 'package.json'));
|
|
147
|
+
if (installed?.version !== version) throw new Error('Update verification failed: installed package has the wrong version.');
|
|
148
|
+
const { stdout } = await runner(process.execPath, [join(root, 'bin.mjs'), '--version'], options);
|
|
149
|
+
if (stdout.trim() !== version) throw new Error('Update verification failed: the installed CLI reports a different version.');
|
|
150
|
+
return { updated: true, version, restartRequired: true };
|
|
151
|
+
} finally { rmSync(lock, { recursive: true, force: true }); }
|
|
152
|
+
}
|