feat(app): user manual — in-app reference and console help
- ZQCommandReference: one registry of every command (syntax, options, examples, notes) shared by the console and the app so they cannot drift; 35 commands across 10 categories - console: 'help' lists the reference by category, 'help <command>' (abbreviations resolve too) renders the full entry - app: Manual pane — searchable sidebar grouped by category, per-command pages (syntax/options/examples), and a 'How Metrika works' overview covering the command grammar, data handling, estimation conventions, the CPU/GPU planner, reproducibility, and user commands - release plumbing: real team ID + notary keychain-profile NAME in release.sh (both configuration, not credentials, per the security conventions), manual Developer ID signing with hardened runtime, -skipPackagePluginValidation, DMG background art - tests: help coverage test pins a manual entry for every implemented verb; UI smoke drives the Manual pane; 106 green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 10 changed files with +698 and −13
modified
Metrika.xcodeproj/project.pbxproj
+4 −0
@@ -14,6 +14,7 @@ | ||
| 14 | 14 | 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 159695CF89D6BB25DF883F07 /* ConsoleView.swift */; }; |
| 15 | 15 | 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */; }; |
| 16 | 16 | CB602D8A4EDDF5D0FBB0CFB7 /* ConsoleSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */; }; |
| 17 | + D01BD59F090074D4C3FC392D /* ManualView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C64C028218A21FE8E5D9D1 /* ManualView.swift */; }; | |
| 17 | 18 | DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD551AA707CD1853C3D6D21 /* PlotView.swift */; }; |
| 18 | 19 | F0A49DC471FA794BE5948A37 /* DataBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8E64F5B2DE7930640AEE29 /* DataBrowserView.swift */; }; |
| 19 | 20 | /* End PBXBuildFile section */ |
@@ -39,6 +40,7 @@ | ||
| 39 | 40 | 5A0213CC9FE05A339DEEFA28 /* Metrika.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metrika.app; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 40 | 41 | 6E94C5817E96C83B888FB579 /* Metrika.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metrika.entitlements; sourceTree = "<group>"; }; |
| 41 | 42 | 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetrikaApp.swift; sourceTree = "<group>"; }; |
| 43 | + A7C64C028218A21FE8E5D9D1 /* ManualView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualView.swift; sourceTree = "<group>"; }; | |
| 42 | 44 | AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleSmokeTests.swift; sourceTree = "<group>"; }; |
| 43 | 45 | C9BB0AC3BAA378743FD57FF7 /* DoFileEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoFileEditorView.swift; sourceTree = "<group>"; }; |
| 44 | 46 | /* End PBXFileReference section */ |
@@ -78,6 +80,7 @@ | ||
| 78 | 80 | 159695CF89D6BB25DF883F07 /* ConsoleView.swift */, |
| 79 | 81 | 2B8E64F5B2DE7930640AEE29 /* DataBrowserView.swift */, |
| 80 | 82 | C9BB0AC3BAA378743FD57FF7 /* DoFileEditorView.swift */, |
| 83 | + A7C64C028218A21FE8E5D9D1 /* ManualView.swift */, | |
| 81 | 84 | 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */, |
| 82 | 85 | 4AD551AA707CD1853C3D6D21 /* PlotView.swift */, |
| 83 | 86 | 0453FB0F256E853D2C2F481C /* SessionModel.swift */, |
@@ -230,6 +233,7 @@ | ||
| 230 | 233 | 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */, |
| 231 | 234 | F0A49DC471FA794BE5948A37 /* DataBrowserView.swift in Sources */, |
| 232 | 235 | 0BD0F927FC614CF19EF3D59D /* DoFileEditorView.swift in Sources */, |
| 236 | + D01BD59F090074D4C3FC392D /* ManualView.swift in Sources */, | |
| 233 | 237 | 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */, |
| 234 | 238 | DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */, |
| 235 | 239 | 7E936A23CCC90823D6B08BCC /* SessionModel.swift in Sources */, |
added
Metrika/Sources/ManualView.swift
+233 −0
@@ -0,0 +1,233 @@ | ||
| 1 | +// | |
| 2 | +// ManualView.swift | |
| 3 | +// Metrika | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved. | |
| 8 | +// | |
| 9 | + | |
| 10 | +import SwiftUI | |
| 11 | +import ZQEngine | |
| 12 | + | |
| 13 | +/// User manual pane: a searchable reference of every command (rendered | |
| 14 | +/// from the same registry as the console's `help`), plus an overview of | |
| 15 | +/// how Metrika works. | |
| 16 | +struct ManualPane: View { | |
| 17 | + @State private var searchText = "" | |
| 18 | + @State private var selection: String? = ManualPane.overviewID | |
| 19 | + | |
| 20 | + static let overviewID = "__overview" | |
| 21 | + | |
| 22 | + private var filteredDocs: [ZQCommandDoc] { | |
| 23 | + let needle = searchText.trimmingCharacters(in: .whitespaces).lowercased() | |
| 24 | + guard !needle.isEmpty else { return ZQCommandReference.all } | |
| 25 | + return ZQCommandReference.all.filter { | |
| 26 | + $0.verb.lowercased().contains(needle) | |
| 27 | + || $0.summary.lowercased().contains(needle) | |
| 28 | + || $0.category.lowercased().contains(needle) | |
| 29 | + } | |
| 30 | + } | |
| 31 | + | |
| 32 | + var body: some View { | |
| 33 | + HSplitView { | |
| 34 | + VStack(spacing: 0) { | |
| 35 | + HStack(spacing: 6) { | |
| 36 | + Image(systemName: "magnifyingglass") | |
| 37 | + .foregroundStyle(.secondary) | |
| 38 | + TextField("Search commands", text: $searchText) | |
| 39 | + .textFieldStyle(.plain) | |
| 40 | + } | |
| 41 | + .padding(8) | |
| 42 | + Divider() | |
| 43 | + List(selection: $selection) { | |
| 44 | + if searchText.isEmpty { | |
| 45 | + Label("How Metrika works", systemImage: "info.circle") | |
| 46 | + .tag(Self.overviewID) | |
| 47 | + } | |
| 48 | + ForEach(ZQCommandReference.categories, id: \.self) { category in | |
| 49 | + let docs = filteredDocs.filter { $0.category == category } | |
| 50 | + if !docs.isEmpty { | |
| 51 | + Section(category) { | |
| 52 | + ForEach(docs) { doc in | |
| 53 | + Text(doc.verb) | |
| 54 | + .fontDesign(.monospaced) | |
| 55 | + .tag(doc.verb) | |
| 56 | + } | |
| 57 | + } | |
| 58 | + } | |
| 59 | + } | |
| 60 | + } | |
| 61 | + .listStyle(.sidebar) | |
| 62 | + } | |
| 63 | + .frame(minWidth: 200, maxWidth: 280) | |
| 64 | + | |
| 65 | + ScrollView { | |
| 66 | + Group { | |
| 67 | + if selection == Self.overviewID { | |
| 68 | + OverviewPage() | |
| 69 | + } else if let doc = ZQCommandReference.all.first(where: { | |
| 70 | + $0.verb == selection | |
| 71 | + }) { | |
| 72 | + CommandPage(doc: doc) | |
| 73 | + } else { | |
| 74 | + ContentUnavailableView( | |
| 75 | + "Select a command", systemImage: "book", | |
| 76 | + description: Text("Pick a topic from the list.") | |
| 77 | + ) | |
| 78 | + } | |
| 79 | + } | |
| 80 | + .frame(maxWidth: 640, alignment: .leading) | |
| 81 | + .padding(24) | |
| 82 | + } | |
| 83 | + .frame(maxWidth: .infinity) | |
| 84 | + } | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +/// One command's reference page. | |
| 89 | +private struct CommandPage: View { | |
| 90 | + let doc: ZQCommandDoc | |
| 91 | + | |
| 92 | + var body: some View { | |
| 93 | + VStack(alignment: .leading, spacing: 14) { | |
| 94 | + HStack(alignment: .firstTextBaseline, spacing: 10) { | |
| 95 | + Text(doc.verb) | |
| 96 | + .font(.title2.weight(.semibold)) | |
| 97 | + .fontDesign(.monospaced) | |
| 98 | + if let abbreviation = doc.abbreviation { | |
| 99 | + Text("abbreviation: \(abbreviation)") | |
| 100 | + .font(.caption) | |
| 101 | + .foregroundStyle(.secondary) | |
| 102 | + } | |
| 103 | + Spacer() | |
| 104 | + Text(doc.category) | |
| 105 | + .font(.caption) | |
| 106 | + .padding(.horizontal, 8) | |
| 107 | + .padding(.vertical, 3) | |
| 108 | + .background(.quaternary, in: Capsule()) | |
| 109 | + } | |
| 110 | + | |
| 111 | + Text(doc.summary) | |
| 112 | + | |
| 113 | + GroupBox("Syntax") { | |
| 114 | + Text(doc.syntax) | |
| 115 | + .fontDesign(.monospaced) | |
| 116 | + .font(.system(size: 12)) | |
| 117 | + .textSelection(.enabled) | |
| 118 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 119 | + .padding(4) | |
| 120 | + } | |
| 121 | + | |
| 122 | + if !doc.options.isEmpty { | |
| 123 | + GroupBox("Options") { | |
| 124 | + VStack(alignment: .leading, spacing: 6) { | |
| 125 | + ForEach(doc.options, id: \.name) { option in | |
| 126 | + HStack(alignment: .firstTextBaseline, spacing: 12) { | |
| 127 | + Text(option.name) | |
| 128 | + .fontDesign(.monospaced) | |
| 129 | + .font(.system(size: 12)) | |
| 130 | + .frame(width: 130, alignment: .leading) | |
| 131 | + Text(option.meaning) | |
| 132 | + .font(.system(size: 12)) | |
| 133 | + } | |
| 134 | + } | |
| 135 | + } | |
| 136 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 137 | + .padding(4) | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + if !doc.examples.isEmpty { | |
| 142 | + GroupBox("Examples") { | |
| 143 | + VStack(alignment: .leading, spacing: 4) { | |
| 144 | + ForEach(doc.examples, id: \.self) { example in | |
| 145 | + Text(". \(example)") | |
| 146 | + .fontDesign(.monospaced) | |
| 147 | + .font(.system(size: 12)) | |
| 148 | + .textSelection(.enabled) | |
| 149 | + } | |
| 150 | + } | |
| 151 | + .frame(maxWidth: .infinity, alignment: .leading) | |
| 152 | + .padding(4) | |
| 153 | + } | |
| 154 | + } | |
| 155 | + | |
| 156 | + if let notes = doc.notes { | |
| 157 | + Text(notes) | |
| 158 | + .font(.callout) | |
| 159 | + .foregroundStyle(.secondary) | |
| 160 | + } | |
| 161 | + } | |
| 162 | + } | |
| 163 | +} | |
| 164 | + | |
| 165 | +/// "How Metrika works" — the architecture from a user's point of view. | |
| 166 | +private struct OverviewPage: View { | |
| 167 | + var body: some View { | |
| 168 | + VStack(alignment: .leading, spacing: 16) { | |
| 169 | + Text("How Metrika works") | |
| 170 | + .font(.title2.weight(.semibold)) | |
| 171 | + | |
| 172 | + section("The command line", """ | |
| 173 | + Everything in Metrika is a command, following one grammar: | |
| 174 | + | |
| 175 | + command [varlist] [if condition] [in range] [, options] | |
| 176 | + | |
| 177 | + Type commands in the Console pane (↑/↓ recall history), run whole \ | |
| 178 | + scripts from the Do-file pane (⌘R runs the selection or the file), \ | |
| 179 | + or filter the Data pane — its filter bar compiles to the same `if` \ | |
| 180 | + expressions. All three routes share one execution engine, so a \ | |
| 181 | + do-file replays exactly what you typed. | |
| 182 | + """) | |
| 183 | + | |
| 184 | + section("Data", """ | |
| 185 | + `use` loads parquet, csv, json, arrow, or Stata .dta files; `save` \ | |
| 186 | + writes them back. Data is columnar in memory (DuckDB underneath), \ | |
| 187 | + numeric values are 64-bit floats, and missing values are tracked \ | |
| 188 | + explicitly — every estimator reports how many observations \ | |
| 189 | + listwise deletion dropped. The sidebar always shows the working \ | |
| 190 | + dataset's variables and missing counts. | |
| 191 | + """) | |
| 192 | + | |
| 193 | + section("Estimation", """ | |
| 194 | + Estimators follow Stata conventions: `reg y x, robust \ | |
| 195 | + cluster(id)` gives HC1 or cluster-robust standard errors, factor \ | |
| 196 | + variables expand with `i.var`, and every CPU result is validated \ | |
| 197 | + against R to 1e-10. After fitting, `predict` generates fitted \ | |
| 198 | + values or residuals and `margins, dydx()` computes average \ | |
| 199 | + marginal effects with delta-method standard errors. | |
| 200 | + """) | |
| 201 | + | |
| 202 | + section("The GPU, invisibly", """ | |
| 203 | + A planner decides where each command runs — you never choose. \ | |
| 204 | + Large bootstrap runs execute batched on the Apple GPU; small jobs \ | |
| 205 | + stay on the CPU. Random numbers come from a counter-based Philox \ | |
| 206 | + generator, so `set seed 42` produces bit-identical resamples on \ | |
| 207 | + either backend, in any chunk order. | |
| 208 | + """) | |
| 209 | + | |
| 210 | + section("Extending Metrika", """ | |
| 211 | + Drop a .zyq script into ~/Library/Application Support/Metrika/\ | |
| 212 | + Commands/ and its filename becomes a command — an optional \ | |
| 213 | + `args name…` first line names the arguments, referenced as \ | |
| 214 | + `name' in the body. Native plugins are Swift types implementing \ | |
| 215 | + ZQCommandPlugin (see `help zscore` for the shipped example). | |
| 216 | + """) | |
| 217 | + | |
| 218 | + section("Reproducibility", """ | |
| 219 | + `log using file` records a session. Seeds are honored across CPU \ | |
| 220 | + and GPU. Do-files replay top to bottom with the errors pointing \ | |
| 221 | + at the failing line. The same engine powers the metrika-cli \ | |
| 222 | + terminal tool, so pipelines can run headless. | |
| 223 | + """) | |
| 224 | + } | |
| 225 | + } | |
| 226 | + | |
| 227 | + private func section(_ title: String, _ body: String) -> some View { | |
| 228 | + VStack(alignment: .leading, spacing: 6) { | |
| 229 | + Text(title).font(.headline) | |
| 230 | + Text(body).font(.callout) | |
| 231 | + } | |
| 232 | + } | |
| 233 | +} | |
modified
Metrika/Sources/MetrikaApp.swift
+3 −0
@@ -28,6 +28,7 @@ struct ContentView: View { | ||
| 28 | 28 | case console = "Console" |
| 29 | 29 | case data = "Data" |
| 30 | 30 | case doFile = "Do-file" |
| 31 | + case manual = "Manual" | |
| 31 | 32 | } |
| 32 | 33 | |
| 33 | 34 | @Environment(SessionModel.self) private var model |
@@ -52,6 +53,8 @@ struct ContentView: View { | ||
| 52 | 53 | DataBrowserPane() |
| 53 | 54 | case .doFile: |
| 54 | 55 | DoFileEditorPane() |
| 56 | + case .manual: | |
| 57 | + ManualPane() | |
| 55 | 58 | } |
| 56 | 59 | } |
| 57 | 60 | .toolbar { |
added
MetrikaKit/Assets/DMG/background.png
+0 −0
Binary file not shown.
added
MetrikaKit/Sources/ZQEngine/CommandHelp.swift
+331 −0
@@ -0,0 +1,331 @@ | ||
| 1 | +// | |
| 2 | +// CommandHelp.swift | |
| 3 | +// Metrika | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved. | |
| 8 | +// | |
| 9 | + | |
| 10 | +/// The in-app command reference (CLAUDE.md §13 v1.0: `help regress` | |
| 11 | +/// in-app). One registry serves both the console `help` command and the | |
| 12 | +/// app's Manual pane, so they can never drift apart. | |
| 13 | +public struct ZQCommandDoc: Identifiable, Equatable, Sendable { | |
| 14 | + public var id: String { verb } | |
| 15 | + public var verb: String | |
| 16 | + /// Shortest accepted abbreviation ("reg" for regress); nil if none. | |
| 17 | + public var abbreviation: String? | |
| 18 | + public var category: String | |
| 19 | + public var summary: String | |
| 20 | + public var syntax: String | |
| 21 | + public var options: [(name: String, meaning: String)] | |
| 22 | + public var examples: [String] | |
| 23 | + public var notes: String? | |
| 24 | + | |
| 25 | + public init( | |
| 26 | + verb: String, abbreviation: String? = nil, category: String, | |
| 27 | + summary: String, syntax: String, | |
| 28 | + options: [(name: String, meaning: String)] = [], | |
| 29 | + examples: [String] = [], notes: String? = nil | |
| 30 | + ) { | |
| 31 | + self.verb = verb | |
| 32 | + self.abbreviation = abbreviation | |
| 33 | + self.category = category | |
| 34 | + self.summary = summary | |
| 35 | + self.syntax = syntax | |
| 36 | + self.options = options | |
| 37 | + self.examples = examples | |
| 38 | + self.notes = notes | |
| 39 | + } | |
| 40 | + | |
| 41 | + public static func == (lhs: ZQCommandDoc, rhs: ZQCommandDoc) -> Bool { | |
| 42 | + lhs.verb == rhs.verb | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +public enum ZQCommandReference { | |
| 47 | + public static let categories = [ | |
| 48 | + "Data management", "Descriptive statistics", "Estimation", | |
| 49 | + "Machine learning", "Resampling & simulation", "Post-estimation", | |
| 50 | + "Graphics", "Panel data", "Session & scripting", "User commands", | |
| 51 | + ] | |
| 52 | + | |
| 53 | + public static func doc(for verb: String) -> ZQCommandDoc? { | |
| 54 | + all.first { $0.verb == verb || $0.abbreviation == verb } | |
| 55 | + } | |
| 56 | + | |
| 57 | + public static let all: [ZQCommandDoc] = [ | |
| 58 | + // ------------------------------------------------ Data management | |
| 59 | + ZQCommandDoc( | |
| 60 | + verb: "use", category: "Data management", | |
| 61 | + summary: "Load a dataset into memory. Format is inferred from the extension: .parquet, .csv, .tsv, .json, .arrow, and Stata .dta (formats 117–119).", | |
| 62 | + syntax: "use filename", | |
| 63 | + examples: ["use sales.parquet", "use ~/data/panel.dta"], | |
| 64 | + notes: "Loading goes through DuckDB for columnar formats and a native reader for .dta. The sandboxed app reads user-selected files and its own container." | |
| 65 | + ), | |
| 66 | + ZQCommandDoc( | |
| 67 | + verb: "save", category: "Data management", | |
| 68 | + summary: "Write the working dataset to disk (.parquet, .csv, or Stata .dta format 118).", | |
| 69 | + syntax: "save filename [, replace]", | |
| 70 | + options: [("replace", "overwrite an existing file")], | |
| 71 | + examples: ["save results.dta, replace"] | |
| 72 | + ), | |
| 73 | + ZQCommandDoc( | |
| 74 | + verb: "clear", category: "Data management", | |
| 75 | + summary: "Drop the working dataset from memory.", | |
| 76 | + syntax: "clear" | |
| 77 | + ), | |
| 78 | + ZQCommandDoc( | |
| 79 | + verb: "describe", abbreviation: "d", category: "Data management", | |
| 80 | + summary: "List variables with their storage types and missing counts.", | |
| 81 | + syntax: "describe" | |
| 82 | + ), | |
| 83 | + ZQCommandDoc( | |
| 84 | + verb: "list", abbreviation: "l", category: "Data management", | |
| 85 | + summary: "Print observations (first 50 shown).", | |
| 86 | + syntax: "list [varlist] [if] [in]", | |
| 87 | + examples: ["list revenue price in 1/10"] | |
| 88 | + ), | |
| 89 | + ZQCommandDoc( | |
| 90 | + verb: "count", abbreviation: "cou", category: "Data management", | |
| 91 | + summary: "Count observations satisfying a condition.", | |
| 92 | + syntax: "count [if]", | |
| 93 | + examples: ["count if revenue > 100 & !missing(price)"] | |
| 94 | + ), | |
| 95 | + ZQCommandDoc( | |
| 96 | + verb: "generate", abbreviation: "gen", category: "Data management", | |
| 97 | + summary: "Create a new variable from an expression. Observations excluded by `if` become missing.", | |
| 98 | + syntax: "generate newvar = expression [if] [in]", | |
| 99 | + examples: [ | |
| 100 | + "gen log_rev = ln(revenue)", | |
| 101 | + "gen high = revenue > 120 if !missing(revenue)", | |
| 102 | + ], | |
| 103 | + notes: "Functions: ln, log, log10, exp, sqrt, abs, floor, ceil, round, int, min, max, missing. Missing values propagate through arithmetic; division by zero and ln of a nonpositive number yield missing." | |
| 104 | + ), | |
| 105 | + ZQCommandDoc( | |
| 106 | + verb: "replace", category: "Data management", | |
| 107 | + summary: "Overwrite values of an existing variable; reports the number of real changes.", | |
| 108 | + syntax: "replace varname = expression [if] [in]", | |
| 109 | + examples: ["replace flag = 0 if missing(flag)"] | |
| 110 | + ), | |
| 111 | + ZQCommandDoc( | |
| 112 | + verb: "drop", category: "Data management", | |
| 113 | + summary: "Remove variables, or observations with `if`/`in`.", | |
| 114 | + syntax: "drop varlist | drop if expression | drop in range", | |
| 115 | + examples: ["drop temp1 temp2", "drop if price < 0"] | |
| 116 | + ), | |
| 117 | + ZQCommandDoc( | |
| 118 | + verb: "keep", category: "Data management", | |
| 119 | + summary: "Keep only the listed variables, or only observations satisfying a condition.", | |
| 120 | + syntax: "keep varlist | keep if expression | keep in range", | |
| 121 | + examples: ["keep in 1/1000"] | |
| 122 | + ), | |
| 123 | + | |
| 124 | + // ------------------------------------------ Descriptive statistics | |
| 125 | + ZQCommandDoc( | |
| 126 | + verb: "summarize", abbreviation: "su", category: "Descriptive statistics", | |
| 127 | + summary: "Means, standard deviations, and ranges; `detail` adds percentiles, skewness, and kurtosis (Stata definitions).", | |
| 128 | + syntax: "summarize [varlist] [if] [in] [, detail]", | |
| 129 | + options: [("detail", "percentiles p1–p99, skewness, kurtosis")], | |
| 130 | + examples: ["summarize revenue price, detail"] | |
| 131 | + ), | |
| 132 | + ZQCommandDoc( | |
| 133 | + verb: "tabulate", abbreviation: "tab", category: "Descriptive statistics", | |
| 134 | + summary: "One-way frequency table or two-way cross-tabulation with totals.", | |
| 135 | + syntax: "tabulate varname [varname2] [if] [, missing]", | |
| 136 | + options: [("missing", "include missing values as a category")], | |
| 137 | + examples: ["tab region", "tabulate region purchase"] | |
| 138 | + ), | |
| 139 | + ZQCommandDoc( | |
| 140 | + verb: "correlate", abbreviation: "cor", category: "Descriptive statistics", | |
| 141 | + summary: "Pearson correlation matrix with listwise deletion.", | |
| 142 | + syntax: "correlate [varlist] [if]", | |
| 143 | + examples: ["correlate revenue price orders"] | |
| 144 | + ), | |
| 145 | + | |
| 146 | + // ------------------------------------------------------ Estimation | |
| 147 | + ZQCommandDoc( | |
| 148 | + verb: "regress", abbreviation: "reg", category: "Estimation", | |
| 149 | + summary: "Ordinary least squares via LAPACK QR. Factor variables (i.var) expand to indicators; c.a#c.b forms continuous interactions.", | |
| 150 | + syntax: "regress depvar [indepvars] [if] [in] [, robust hc2 hc3 cluster(varname) noconstant level(#)]", | |
| 151 | + options: [ | |
| 152 | + ("robust", "HC1 heteroskedasticity-consistent SE (Stata's robust)"), | |
| 153 | + ("hc2 / hc3", "leverage-adjusted sandwich estimators"), | |
| 154 | + ("cluster(var)", "cluster-robust SE, t on G−1 df"), | |
| 155 | + ("noconstant", "suppress the intercept"), | |
| 156 | + ("level(#)", "confidence level, default 95"), | |
| 157 | + ], | |
| 158 | + examples: [ | |
| 159 | + "reg log_rev price, robust", | |
| 160 | + "reg log_rev price i.region, cluster(firm_id)", | |
| 161 | + "reg wage c.age#c.age education", | |
| 162 | + ], | |
| 163 | + notes: "Results match R to 1e-10. Listwise deletion is reported explicitly." | |
| 164 | + ), | |
| 165 | + ZQCommandDoc( | |
| 166 | + verb: "logit", category: "Estimation", | |
| 167 | + summary: "Logistic regression by Fisher scoring; reports LR χ², McFadden pseudo-R², z statistics.", | |
| 168 | + syntax: "logit depvar indepvars [if] [, robust cluster(varname) level(#)]", | |
| 169 | + examples: ["logit purchase price, robust"] | |
| 170 | + ), | |
| 171 | + ZQCommandDoc( | |
| 172 | + verb: "probit", category: "Estimation", | |
| 173 | + summary: "Probit regression (normal link), same options as logit.", | |
| 174 | + syntax: "probit depvar indepvars [if] [, robust cluster(varname)]", | |
| 175 | + examples: ["probit purchase price"] | |
| 176 | + ), | |
| 177 | + ZQCommandDoc( | |
| 178 | + verb: "poisson", category: "Estimation", | |
| 179 | + summary: "Poisson regression for counts (log link).", | |
| 180 | + syntax: "poisson depvar indepvars [if] [, robust cluster(varname)]", | |
| 181 | + examples: ["poisson orders price, robust"] | |
| 182 | + ), | |
| 183 | + ZQCommandDoc( | |
| 184 | + verb: "ivregress", category: "Estimation", | |
| 185 | + summary: "Two-stage least squares. Endogenous regressors and their instruments go in the parenthesized group.", | |
| 186 | + syntax: "ivregress 2sls depvar [exogvars] (endogvars = instruments) [, robust cluster(varname)]", | |
| 187 | + examples: ["ivregress 2sls log_rev (price = z1 z2), robust"], | |
| 188 | + notes: "Residuals come from the original regressors; inference follows Stata's `small` convention (t statistics on N−K df)." | |
| 189 | + ), | |
| 190 | + ZQCommandDoc( | |
| 191 | + verb: "xtreg", category: "Estimation", | |
| 192 | + summary: "Panel fixed-effects (within) estimator. Declare the panel with xtset first.", | |
| 193 | + syntax: "xtreg depvar indepvars, fe [cluster(panelvar)]", | |
| 194 | + options: [ | |
| 195 | + ("fe", "fixed effects (required — the only estimator so far)"), | |
| 196 | + ("cluster(panelvar)", "panel-clustered SE, t on G−1 df"), | |
| 197 | + ], | |
| 198 | + examples: ["xtset firm_id", "xtreg log_rev price, fe cluster(firm_id)"] | |
| 199 | + ), | |
| 200 | + | |
| 201 | + // ------------------------------------------------ Machine learning | |
| 202 | + ZQCommandDoc( | |
| 203 | + verb: "lasso", category: "Machine learning", | |
| 204 | + summary: "L1-penalized linear regression (coordinate descent, glmnet conventions). Selects variables by zeroing coefficients.", | |
| 205 | + syntax: "lasso depvar indepvars, lambda(#)", | |
| 206 | + options: [("lambda(#)", "penalty strength; omit it to see lambda_max for your data")], | |
| 207 | + examples: ["lasso log_rev price z1 z2 orders, lambda(0.05)"] | |
| 208 | + ), | |
| 209 | + ZQCommandDoc( | |
| 210 | + verb: "elasticnet", category: "Machine learning", | |
| 211 | + summary: "Elastic-net linear regression mixing L1 and L2 penalties.", | |
| 212 | + syntax: "elasticnet depvar indepvars, lambda(#) [alpha(#)]", | |
| 213 | + options: [ | |
| 214 | + ("alpha(#)", "1 = lasso, 0 = ridge; default 1"), | |
| 215 | + ("lambda(#)", "penalty strength"), | |
| 216 | + ], | |
| 217 | + examples: ["elasticnet log_rev price z1 z2, alpha(0.4) lambda(0.02)"], | |
| 218 | + notes: "Matches R glmnet, including its gaussian y-standardization convention (ridge penalties scale with sd of the response)." | |
| 219 | + ), | |
| 220 | + | |
| 221 | + // ------------------------------------------ Resampling & simulation | |
| 222 | + ZQCommandDoc( | |
| 223 | + verb: "bootstrap", category: "Resampling & simulation", | |
| 224 | + summary: "Pairs bootstrap of a regression. Large replication counts run batched on the Apple GPU; results are identical either way for a given seed.", | |
| 225 | + syntax: "bootstrap, reps(#) [seed(#)]: regress depvar indepvars", | |
| 226 | + options: [ | |
| 227 | + ("reps(#)", "number of replications"), | |
| 228 | + ("seed(#)", "Philox seed — full reproducibility across CPU and GPU"), | |
| 229 | + ], | |
| 230 | + examples: ["bootstrap, reps(10000) seed(42): reg log_rev price"], | |
| 231 | + notes: "Replicates are counter-addressable: any subset recomputes identically regardless of chunking or backend. The planner dispatches ≥500 reps to the GPU when available." | |
| 232 | + ), | |
| 233 | + ZQCommandDoc( | |
| 234 | + verb: "permute", category: "Resampling & simulation", | |
| 235 | + summary: "Permutation test: the response is permuted, the model refit, and empirical two-sided p-values reported per coefficient.", | |
| 236 | + syntax: "permute, reps(#) [seed(#)]: regress depvar indepvars", | |
| 237 | + examples: ["permute, reps(1000) seed(42): reg log_rev orders"] | |
| 238 | + ), | |
| 239 | + ZQCommandDoc( | |
| 240 | + verb: "set", category: "Resampling & simulation", | |
| 241 | + summary: "Set session parameters. `set seed` fixes the Philox key used by bootstrap and permute.", | |
| 242 | + syntax: "set seed #", | |
| 243 | + examples: ["set seed 42"] | |
| 244 | + ), | |
| 245 | + | |
| 246 | + // ------------------------------------------------- Post-estimation | |
| 247 | + ZQCommandDoc( | |
| 248 | + verb: "predict", category: "Post-estimation", | |
| 249 | + summary: "Generate predictions from the last estimation over all current observations.", | |
| 250 | + syntax: "predict newvar [, xb residuals pr n]", | |
| 251 | + options: [ | |
| 252 | + ("xb", "linear prediction (default after regress/ivregress)"), | |
| 253 | + ("residuals", "response residuals"), | |
| 254 | + ("pr", "predicted probability (default after logit/probit)"), | |
| 255 | + ("n", "predicted mean count (default after poisson)"), | |
| 256 | + ], | |
| 257 | + examples: ["reg log_rev price", "predict yhat", "predict e, residuals"] | |
| 258 | + ), | |
| 259 | + ZQCommandDoc( | |
| 260 | + verb: "margins", abbreviation: "marg", category: "Post-estimation", | |
| 261 | + summary: "Average marginal effects with delta-method standard errors. After OLS/IV the effect is the coefficient; after logit/probit/poisson it averages dμ/dx over the estimation sample.", | |
| 262 | + syntax: "margins, dydx(varlist) [level(#)]", | |
| 263 | + examples: ["logit purchase price", "margins, dydx(price)"], | |
| 264 | + notes: "Continuous regressors only for now; factor and interaction terms are rejected with a message." | |
| 265 | + ), | |
| 266 | + | |
| 267 | + // ------------------------------------------------------- Graphics | |
| 268 | + ZQCommandDoc( | |
| 269 | + verb: "scatter", category: "Graphics", | |
| 270 | + summary: "Scatter plot (also available as `graph scatter`). `by()` splits into colored series.", | |
| 271 | + syntax: "scatter yvar xvar [if] [, by(varname)]", | |
| 272 | + examples: ["scatter log_rev price, by(region)"] | |
| 273 | + ), | |
| 274 | + ZQCommandDoc( | |
| 275 | + verb: "histogram", abbreviation: "hist", category: "Graphics", | |
| 276 | + summary: "Frequency histogram of one variable (Sturges bins by default).", | |
| 277 | + syntax: "histogram varname [if] [, bins(#)]", | |
| 278 | + options: [("bins(#)", "override the bin count")], | |
| 279 | + examples: ["histogram revenue, bins(12)"] | |
| 280 | + ), | |
| 281 | + ZQCommandDoc( | |
| 282 | + verb: "kdensity", abbreviation: "kden", category: "Graphics", | |
| 283 | + summary: "Kernel density estimate (Epanechnikov kernel, Silverman bandwidth).", | |
| 284 | + syntax: "kdensity varname [if]", | |
| 285 | + examples: ["kdensity revenue"] | |
| 286 | + ), | |
| 287 | + ZQCommandDoc( | |
| 288 | + verb: "graph", abbreviation: "gr", category: "Graphics", | |
| 289 | + summary: "General plotting front end: graph scatter, graph line, graph histogram.", | |
| 290 | + syntax: "graph scatter|line yvar xvar [if] [, by(varname)]", | |
| 291 | + examples: ["graph line gdp year"] | |
| 292 | + ), | |
| 293 | + | |
| 294 | + // ------------------------------------------------------ Panel data | |
| 295 | + ZQCommandDoc( | |
| 296 | + verb: "xtset", category: "Panel data", | |
| 297 | + summary: "Declare the panel structure (unit and, optionally, time variable) for xtreg.", | |
| 298 | + syntax: "xtset panelvar [timevar]", | |
| 299 | + examples: ["xtset firm_id year"] | |
| 300 | + ), | |
| 301 | + | |
| 302 | + // ---------------------------------------------- Session & scripting | |
| 303 | + ZQCommandDoc( | |
| 304 | + verb: "display", abbreviation: "di", category: "Session & scripting", | |
| 305 | + summary: "Evaluate and print an expression.", | |
| 306 | + syntax: "display expression", | |
| 307 | + examples: ["display 2 + 2 * 3", "display ln(100)"] | |
| 308 | + ), | |
| 309 | + ZQCommandDoc( | |
| 310 | + verb: "log", category: "Session & scripting", | |
| 311 | + summary: "Record commands and output to a text file.", | |
| 312 | + syntax: "log using filename | log close", | |
| 313 | + examples: ["log using session.log"] | |
| 314 | + ), | |
| 315 | + ZQCommandDoc( | |
| 316 | + verb: "help", category: "Session & scripting", | |
| 317 | + summary: "Show this reference, or the entry for one command.", | |
| 318 | + syntax: "help [command]", | |
| 319 | + examples: ["help regress"] | |
| 320 | + ), | |
| 321 | + | |
| 322 | + // ---------------------------------------------------- User commands | |
| 323 | + ZQCommandDoc( | |
| 324 | + verb: "zscore", category: "User commands", | |
| 325 | + summary: "Sample native plugin: generates z_varname, the standardized version of a variable.", | |
| 326 | + syntax: "zscore varname", | |
| 327 | + examples: ["zscore revenue"], | |
| 328 | + notes: "Native plugins are Swift types conforming to ZQCommandPlugin, compiled into the app. Script commands are .zyq files in ~/Library/Application Support/Metrika/Commands/ — an optional leading `args name…` line names positional arguments, referenced as `name' or `1' in the body." | |
| 329 | + ), | |
| 330 | + ] | |
| 331 | +} | |
modified
MetrikaKit/Sources/ZQEngine/Session.swift
+51 −0
@@ -215,6 +215,7 @@ public actor ZQSession { | ||
| 215 | 215 | promoted.verb = "graph" |
| 216 | 216 | return try handleGraph(promoted) |
| 217 | 217 | case "log": return try handleLog(command) |
| 218 | + case "help": return handleHelp(command) | |
| 218 | 219 | default: |
| 219 | 220 | throw ZQEngineError("command '\(command.verb)' is not implemented yet") |
| 220 | 221 | } |
@@ -525,6 +526,56 @@ public actor ZQSession { | ||
| 525 | 526 | return ZQResult(text: "(log started: \(url.path))") |
| 526 | 527 | } |
| 527 | 528 | |
| 529 | + /// `help [command]` — renders the shared command reference. | |
| 530 | + private func handleHelp(_ command: ZQCommand) -> ZQResult { | |
| 531 | + let requested = command.varlist.flatMap(\.referencedNames).first | |
| 532 | + ?? command.argument | |
| 533 | + | |
| 534 | + if let requested { | |
| 535 | + guard let doc = ZQCommandReference.doc(for: requested) else { | |
| 536 | + return ZQResult(text: "help: no entry for '\(requested)' — type 'help' for the full list") | |
| 537 | + } | |
| 538 | + var lines = [ | |
| 539 | + "\(doc.verb)\(doc.abbreviation.map { " (abbreviation: \($0))" } ?? "")", | |
| 540 | + String(repeating: "-", count: 60), | |
| 541 | + doc.summary, | |
| 542 | + "", | |
| 543 | + "Syntax: \(doc.syntax)", | |
| 544 | + ] | |
| 545 | + if !doc.options.isEmpty { | |
| 546 | + lines.append("") | |
| 547 | + lines.append("Options:") | |
| 548 | + for option in doc.options { | |
| 549 | + lines.append(" " + TableFormatter.pad(option.name, 16, right: false) + option.meaning) | |
| 550 | + } | |
| 551 | + } | |
| 552 | + if !doc.examples.isEmpty { | |
| 553 | + lines.append("") | |
| 554 | + lines.append("Examples:") | |
| 555 | + for example in doc.examples { lines.append(" . \(example)") } | |
| 556 | + } | |
| 557 | + if let notes = doc.notes { | |
| 558 | + lines.append("") | |
| 559 | + lines.append(notes) | |
| 560 | + } | |
| 561 | + return ZQResult(text: lines.joined(separator: "\n")) | |
| 562 | + } | |
| 563 | + | |
| 564 | + var lines = ["Metrika command reference — 'help <command>' for details", ""] | |
| 565 | + for category in ZQCommandReference.categories { | |
| 566 | + let docs = ZQCommandReference.all.filter { $0.category == category } | |
| 567 | + guard !docs.isEmpty else { continue } | |
| 568 | + lines.append(category) | |
| 569 | + for doc in docs { | |
| 570 | + lines.append( | |
| 571 | + " " + TableFormatter.pad(doc.verb, 14, right: false) + doc.summary.prefix(60) | |
| 572 | + ) | |
| 573 | + } | |
| 574 | + lines.append("") | |
| 575 | + } | |
| 576 | + return ZQResult(text: lines.joined(separator: "\n")) | |
| 577 | + } | |
| 578 | + | |
| 528 | 579 | private func appendToLog(command: String, output: String) throws { |
| 529 | 580 | guard let logFileURL else { return } |
| 530 | 581 | let entry = ". \(command)\n\(output)\n\n" |
modified
MetrikaKit/Tests/MetrikaKitTests/EngineTests.swift
+30 −0
@@ -183,6 +183,36 @@ struct EngineTests { | ||
| 183 | 183 | #expect(result.scalars["N"] == fixtures["ols_n"]) |
| 184 | 184 | } |
| 185 | 185 | |
| 186 | + @Test("help lists every category; help <verb> shows the entry") | |
| 187 | + func help() async throws { | |
| 188 | + let index = try await session.execute("help") | |
| 189 | + for category in ZQCommandReference.categories { | |
| 190 | + #expect(index.text.contains(category), Comment(rawValue: category)) | |
| 191 | + } | |
| 192 | + let entry = try await session.execute("help regress") | |
| 193 | + #expect(entry.text.contains("cluster(var)")) | |
| 194 | + #expect(entry.text.contains("reg log_rev price, robust")) | |
| 195 | + let byAbbreviation = try await session.execute("help reg") | |
| 196 | + #expect(byAbbreviation.text.contains("Ordinary least squares")) | |
| 197 | + let unknown = try await session.execute("help nosuchthing") | |
| 198 | + #expect(unknown.text.contains("no entry")) | |
| 199 | + } | |
| 200 | + | |
| 201 | + @Test("every dispatched estimation and data verb has a manual entry") | |
| 202 | + func manualCoverage() { | |
| 203 | + let documented = Set(ZQCommandReference.all.map(\.verb)) | |
| 204 | + for verb in [ | |
| 205 | + "use", "save", "clear", "describe", "list", "count", "generate", | |
| 206 | + "replace", "drop", "keep", "summarize", "tabulate", "correlate", | |
| 207 | + "regress", "logit", "probit", "poisson", "ivregress", "xtreg", | |
| 208 | + "xtset", "lasso", "elasticnet", "bootstrap", "permute", "predict", | |
| 209 | + "margins", "scatter", "histogram", "kdensity", "graph", "display", | |
| 210 | + "set", "log", "help", "zscore", | |
| 211 | + ] { | |
| 212 | + #expect(documented.contains(verb), Comment(rawValue: "missing manual entry: \(verb)")) | |
| 213 | + } | |
| 214 | + } | |
| 215 | + | |
| 186 | 216 | @Test("display evaluates scalar expressions") |
| 187 | 217 | func display() async throws { |
| 188 | 218 | let result = try await session.execute("display 2 + 2 * 3") |
modified
Tests/UITests/ConsoleSmokeTests.swift
+16 −0
@@ -124,6 +124,22 @@ final class ConsoleSmokeTests: XCTestCase { | ||
| 124 | 124 | staticText(containing: "high", in: app).waitForExistence(timeout: 5), |
| 125 | 125 | "sidebar missing variable generated by do-file" |
| 126 | 126 | ) |
| 127 | + | |
| 128 | + // Manual pane: overview page and a command entry render. | |
| 129 | + switchPane(to: "Manual", in: app) | |
| 130 | + XCTAssertTrue( | |
| 131 | + staticText(containing: "How Metrika works", in: app) | |
| 132 | + .waitForExistence(timeout: 5), | |
| 133 | + "manual overview missing" | |
| 134 | + ) | |
| 135 | + let regressEntry = app.staticTexts["regress"].firstMatch | |
| 136 | + XCTAssertTrue(regressEntry.waitForExistence(timeout: 5), "regress entry missing") | |
| 137 | + regressEntry.click() | |
| 138 | + XCTAssertTrue( | |
| 139 | + staticText(containing: "Ordinary least squares", in: app) | |
| 140 | + .waitForExistence(timeout: 5), | |
| 141 | + "regress manual page missing" | |
| 142 | + ) | |
| 127 | 143 | } |
| 128 | 144 | |
| 129 | 145 | /// The pane switcher is a segmented picker in the toolbar; SwiftUI |
modified
scripts/ExportOptions.plist
+3 −2
@@ -3,7 +3,8 @@ | ||
| 3 | 3 | "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| 4 | 4 | <plist version="1.0"><dict> |
| 5 | 5 | <key>method</key><string>developer-id</string> |
| 6 | − <key>teamID</key><string><TEAM_ID></string> | |
| 7 | − <key>signingStyle</key><string>automatic</string> | |
| 6 | + <key>teamID</key><string>3YM54G49SN</string> | |
| 7 | + <key>signingStyle</key><string>manual</string> | |
| 8 | + <key>signingCertificate</key><string>Developer ID Application</string> | |
| 8 | 9 | <key>destination</key><string>export</string> |
| 9 | 10 | </dict></plist> |
modified
scripts/release.sh
+27 −11
@@ -1,17 +1,15 @@ | ||
| 1 | 1 | #!/bin/bash |
| 2 | 2 | # |
| 3 | 3 | # release.sh — Metrika |
| 4 | −# | |
| 5 | 4 | # Author: Simon-Pierre Boucher |
| 6 | 5 | # Contact: contact@spboucher.ai |
| 7 | 6 | # Copyright © 2026 Simon-Pierre Boucher. All rights reserved. |
| 8 | 7 | # |
| 9 | 8 | # Release pipeline (CLAUDE.md §10): archive → export → verify → DMG → |
| 10 | −# sign → notarize → staple. | |
| 11 | −# | |
| 12 | −# ⚠️ PLACEHOLDERS: fill <TEAM_ID> and <KEYCHAIN_PROFILE_NAME> from the | |
| 13 | −# local credentials folder before the first release build. Never commit | |
| 14 | −# real credentials. | |
| 9 | +# sign → notarize → staple. The team ID and keychain-profile NAME are | |
| 10 | +# configuration, not credentials — the actual Apple ID and app-specific | |
| 11 | +# password live only in the login keychain (stored once via | |
| 12 | +# `xcrun notarytool store-credentials "MacLustr-Notarize"`). | |
| 15 | 13 | # |
| 16 | 14 | set -euo pipefail |
| 17 | 15 | |
@@ -19,10 +17,27 @@ APP="Metrika" | ||
| 19 | 17 | SCHEME="Metrika" |
| 20 | 18 | BUILD_DIR="build" |
| 21 | 19 | DMG="${APP}.dmg" |
| 20 | +TEAM_ID="3YM54G49SN" | |
| 21 | +NOTARY_PROFILE="MacLustr-Notarize" | |
| 22 | +SIGN_IDENTITY="Developer ID Application: Simon-Pierre Boucher (${TEAM_ID})" | |
| 23 | + | |
| 24 | +cd "$(dirname "$0")/.." | |
| 25 | +rm -rf "$BUILD_DIR" "$DMG" | |
| 22 | 26 | |
| 23 | −# 1. Archive | |
| 24 | −xcodebuild -scheme "$SCHEME" -configuration Release -arch arm64 \ | |
| 25 | − -archivePath "$BUILD_DIR/$APP.xcarchive" archive | |
| 27 | +# 0. Regenerate the Xcode project and the icon from their sources. | |
| 28 | +xcodegen generate | |
| 29 | +./scripts/make_icns.sh | |
| 30 | + | |
| 31 | +# 1. Archive (Developer ID signing; mlx-swift needs the plugin skip). | |
| 32 | +xcodebuild -project "$APP.xcodeproj" -scheme "$SCHEME" \ | |
| 33 | + -configuration Release -arch arm64 \ | |
| 34 | + -archivePath "$BUILD_DIR/$APP.xcarchive" \ | |
| 35 | + -skipPackagePluginValidation \ | |
| 36 | + DEVELOPMENT_TEAM="$TEAM_ID" \ | |
| 37 | + CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \ | |
| 38 | + CODE_SIGN_STYLE=Manual \ | |
| 39 | + OTHER_CODE_SIGN_FLAGS="--timestamp --options runtime" \ | |
| 40 | + archive | |
| 26 | 41 | |
| 27 | 42 | # 2. Export with Developer ID |
| 28 | 43 | xcodebuild -exportArchive \ |
@@ -42,14 +57,15 @@ create-dmg \ | ||
| 42 | 57 | --icon "$APP.app" 130 190 \ |
| 43 | 58 | --app-drop-link 400 190 \ |
| 44 | 59 | --background "Assets/DMG/background.png" \ |
| 60 | + --no-internet-enable \ | |
| 45 | 61 | "$DMG" "$BUILD_DIR/export/" |
| 46 | 62 | |
| 47 | 63 | # 5. Sign the DMG itself |
| 48 | −codesign --sign "Developer ID Application: Simon-Pierre Boucher (<TEAM_ID>)" "$DMG" | |
| 64 | +codesign --sign "$SIGN_IDENTITY" --timestamp "$DMG" | |
| 49 | 65 | |
| 50 | 66 | # 6. Notarize & wait |
| 51 | 67 | xcrun notarytool submit "$DMG" \ |
| 52 | − --keychain-profile "<KEYCHAIN_PROFILE_NAME>" \ | |
| 68 | + --keychain-profile "$NOTARY_PROFILE" \ | |
| 53 | 69 | --wait |
| 54 | 70 | |
| 55 | 71 | # 7. Staple |
| 56 | 72 | |