mixdog 1.1.0 → 1.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.
Files changed (174) hide show
  1. package/README.md +13 -6
  2. package/package.json +1 -2
  3. package/scripts/latest-guard.mjs +63 -0
  4. package/scripts/lib/run-node-tests.mjs +103 -27
  5. package/scripts/lib/test-failure-summary.mjs +7 -2
  6. package/scripts/merge-mac-update-feed.mjs +107 -0
  7. package/scripts/mobile-release.mjs +210 -0
  8. package/scripts/post-release-check.mjs +353 -0
  9. package/scripts/publish-asset-release.mjs +43 -0
  10. package/scripts/release-guard.mjs +63 -0
  11. package/scripts/release-paths.mjs +39 -2
  12. package/scripts/test.mjs +29 -7
  13. package/src/defaults/shipped-history.json +2 -0
  14. package/src/defaults/skills/computer-use/SKILL.md +1 -1
  15. package/src/defaults/skills/setup/references/actions.md +1 -0
  16. package/src/headless-role.mjs +2 -2
  17. package/src/lib/keychain-cjs.cjs +582 -17
  18. package/src/lib/rules-builder.cjs +0 -23
  19. package/src/rules/lead/LEAD.md +2 -2
  20. package/src/runtime/agent/orchestrator/config-storage.mjs +32 -4
  21. package/src/runtime/agent/orchestrator/config.mjs +18 -0
  22. package/src/runtime/agent/orchestrator/context/instruction-files.mjs +130 -0
  23. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/providers/lib/oauth-token-utils.mjs +11 -0
  25. package/src/runtime/agent/orchestrator/providers/lib/open-login-url.mjs +9 -0
  26. package/src/runtime/agent/orchestrator/providers/mimo-oauth-login.mjs +254 -0
  27. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +218 -0
  28. package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +239 -0
  29. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +233 -0
  30. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-login.mjs +247 -0
  31. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +225 -0
  32. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +542 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +25 -0
  34. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +4 -1
  35. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +14 -6
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +99 -0
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +4 -8
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +2 -7
  40. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +8 -0
  41. package/src/runtime/agent/orchestrator/providers/registry.mjs +4 -0
  42. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +2 -0
  43. package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +15 -1
  44. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +1 -5
  45. package/src/runtime/agent/orchestrator/session/context-utils.mjs +12 -14
  46. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +11 -1
  47. package/src/runtime/agent/orchestrator/session/loop/tool-call-ids.mjs +90 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +19 -11
  49. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +1 -0
  50. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +28 -0
  51. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +8 -0
  52. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +3 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +1 -5
  54. package/src/runtime/agent/orchestrator/session/read-dedup.mjs +0 -1
  55. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +3 -0
  56. package/src/runtime/agent/orchestrator/session/tool-batch/finalize.mjs +2 -0
  57. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +2 -0
  58. package/src/runtime/agent/orchestrator/session/transcript-restore/tool-result-status.mjs +23 -0
  59. package/src/runtime/agent/orchestrator/session/turn-effort.mjs +18 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +11 -8
  61. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +9 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/bashism-rewrite.mjs +245 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/command-preflight.mjs +13 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/completed-result.mjs +3 -1
  65. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +2 -2
  66. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/result-format.mjs +49 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +3 -3
  68. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -7
  69. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +61 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +28 -3
  71. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +73 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +2 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +3 -1
  74. package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +17 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +23 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +93 -43
  77. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +32 -6
  78. package/src/runtime/agent/orchestrator/tools/builtin.mjs +9 -2
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +3 -1
  80. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background/promote-job.mjs +12 -15
  81. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background.mjs +2 -2
  82. package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +4 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -91
  84. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +19 -4
  85. package/src/runtime/channels/index.mjs +1 -5
  86. package/src/runtime/channels/lib/runtime-paths.mjs +8 -23
  87. package/src/runtime/channels/lib/tool-dispatch.mjs +1 -13
  88. package/src/runtime/channels/lib/worker-ipc.mjs +2 -2
  89. package/src/runtime/channels/lib/worker-main.mjs +4 -34
  90. package/src/runtime/computer-bridge/client.mjs +25 -1
  91. package/src/runtime/computer-bridge/linux-action-capabilities.mjs +84 -0
  92. package/src/runtime/computer-bridge/linux-readiness.d.mts +52 -0
  93. package/src/runtime/computer-bridge/linux-readiness.mjs +411 -0
  94. package/src/runtime/computer-bridge/test-linux-display.mjs +5 -0
  95. package/src/runtime/effort-judge/compact-tokenizer.mjs +13 -8
  96. package/src/runtime/effort-judge/judge-input.mjs +5 -2
  97. package/src/runtime/effort-judge/model-install.mjs +9 -3
  98. package/src/runtime/effort-judge/model-manifest.json +7 -7
  99. package/src/runtime/memory/lib/embedding-worker.mjs +9 -3
  100. package/src/runtime/memory/lib/pg/supervisor.mjs +3 -0
  101. package/src/runtime/shared/config.mjs +50 -2
  102. package/src/runtime/shared/developer-options.mjs +7 -0
  103. package/src/runtime/shared/instruction-files-mode.mjs +9 -0
  104. package/src/runtime/shared/llm/model-catalog.mjs +2 -1
  105. package/src/runtime/shared/llm/openai-daybreak.mjs +79 -0
  106. package/src/runtime/shared/pristine-execution-contract.json +9 -0
  107. package/src/runtime/shared/pristine-execution.mjs +1 -1
  108. package/src/runtime/shared/provider-accounts.mjs +2 -0
  109. package/src/runtime/shared/resource-admission.mjs +10 -3
  110. package/src/runtime/shared/schedule-model-ref.mjs +11 -0
  111. package/src/runtime/shared/semver.d.mts +1 -0
  112. package/src/runtime/shared/semver.mjs +57 -0
  113. package/src/runtime/shared/session-approval-wait.mjs +35 -0
  114. package/src/runtime/shared/shell-display.mjs +4 -2
  115. package/src/runtime/shared/tool-surface.mjs +2 -2
  116. package/src/runtime/shared/turn-worktree-snapshot.mjs +11 -10
  117. package/src/runtime/shared/update-checker.mjs +2 -31
  118. package/src/session-runtime/boot/apis.mjs +5 -0
  119. package/src/session-runtime/boot/begin.mjs +0 -2
  120. package/src/session-runtime/boot/tools.mjs +5 -4
  121. package/src/session-runtime/model-recency.mjs +3 -0
  122. package/src/session-runtime/provider-auth/login.mjs +10 -10
  123. package/src/session-runtime/provider-auth/setup.mjs +22 -2
  124. package/src/session-runtime/provider-models/row-hydration.mjs +12 -4
  125. package/src/session-runtime/provider-readiness.mjs +4 -0
  126. package/src/session-runtime/resource-mcp-api.mjs +7 -3
  127. package/src/session-runtime/runtime-bootstrap.mjs +0 -4
  128. package/src/session-runtime/runtime-facade.mjs +5 -0
  129. package/src/session-runtime/runtime-paths.mjs +0 -1
  130. package/src/session-runtime/schedule-session-run.mjs +3 -1
  131. package/src/session-runtime/services/agent-tool/index-write-queue.mjs +55 -14
  132. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +15 -3
  133. package/src/session-runtime/services/agent-tool/job-views/session-progress.mjs +2 -0
  134. package/src/session-runtime/services/agent-tool/worker-index/approval-wait.mjs +97 -0
  135. package/src/session-runtime/services/agent-tool/worker-index/mutation-batch.mjs +4 -2
  136. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -2
  137. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +3 -0
  138. package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +58 -2
  139. package/src/session-runtime/services/agent-tool/worker-index.mjs +69 -15
  140. package/src/session-runtime/services/agent-tool/worker-rows.mjs +2 -0
  141. package/src/session-runtime/services/channel-admin.mjs +4 -2
  142. package/src/session-runtime/services/channel-worker.mjs +1 -1
  143. package/src/session-runtime/services/provider-admin.mjs +80 -17
  144. package/src/session-runtime/services/seeds.mjs +7 -10
  145. package/src/session-runtime/services/usage-dashboard-model.mjs +6 -0
  146. package/src/session-runtime/settings-builtin-tools-api.mjs +15 -0
  147. package/src/session-runtime/settings-profile-api.mjs +27 -2
  148. package/src/session-runtime/setup-tool/executor.mjs +5 -0
  149. package/src/session-runtime/setup-tool/tool-defs.mjs +8 -0
  150. package/src/session-runtime/starter-guide-state.mjs +62 -0
  151. package/src/session-runtime/tool-policy-refresh.mjs +3 -0
  152. package/src/session-runtime/webhook-session-run.mjs +3 -1
  153. package/src/session-runtime/workflow-agents-api/onboarding.mjs +3 -2
  154. package/src/standalone/channel-binding.mjs +1 -2
  155. package/src/standalone/channel-transport/call-dispatch.mjs +4 -10
  156. package/src/standalone/daemon-channels-loader.mjs +2 -3
  157. package/src/standalone/local-session-runtime.mjs +2 -78
  158. package/src/standalone/session-protocol.mjs +2 -0
  159. package/src/tui/app/model-options.mjs +13 -1
  160. package/src/tui/app/model-picker/route-selection/fast-parameters.mjs +10 -4
  161. package/src/tui/app/provider-setup/oauth-actions.mjs +5 -0
  162. package/src/tui/dist/index.mjs +84 -34
  163. package/src/tui/session/context-state.mjs +2 -0
  164. package/src/tui/session/live-share/owner.mjs +17 -0
  165. package/src/tui/session/oauth-flows.mjs +2 -0
  166. package/src/tui/session/session-api/extensions.mjs +12 -8
  167. package/src/tui/session/session-api/integrations.mjs +9 -8
  168. package/src/tui/session/session-api/routes.mjs +2 -2
  169. package/src/tui/session/session-api/settings.mjs +1 -0
  170. package/src/tui/session/session-api/shared.mjs +10 -6
  171. package/src/tui/session/tool-card-results/grouped-result.mjs +11 -2
  172. package/src/tui/session/transcript-spill/spill-dir.mjs +22 -6
  173. package/src/tui/spinner-meta.mjs +26 -10
  174. package/src/runtime/channels/tool-defs.mjs +0 -17
@@ -0,0 +1,210 @@
1
+ // The mobile (Android APK + iOS TestFlight) side of Deploy.
2
+ //
3
+ // plan: whether this deploy ships a mobile release and under which version.
4
+ // A release is due when anything the app binaries are built from changed
5
+ // since the newest mobile-v* tag (input `auto`), always (`force`), or never
6
+ // (`skip`). The version is the next patch (or minor) of that tag. The tag
7
+ // itself is created by the Android release, so a failed mobile release
8
+ // leaves no tag and the next deploy plans the same version again.
9
+ // CLI: MOBILE_MODE=auto|force|skip MOBILE_BUMP=patch|minor node scripts/mobile-release.mjs plan
10
+ // appends mobile_release / mobile_version / mobile_tag to $GITHUB_OUTPUT.
11
+ //
12
+ // link: the Android release the relay's /app page links — the newest
13
+ // published (non-draft) mobile-v* release whose APK actually downloads, or
14
+ // the releases list when there is none. `--require=<tag>` instead fails
15
+ // unless that exact release is the one that resolves (post-publish check).
16
+ // CLI: GITHUB_REPOSITORY=o/r GH_TOKEN=… node scripts/mobile-release.mjs link [--require=mobile-vX.Y.Z]
17
+ // appends url / tag to $GITHUB_OUTPUT.
18
+ import { appendFileSync } from 'node:fs';
19
+ import { execFileSync, spawnSync } from 'node:child_process';
20
+ import { setTimeout as delay } from 'node:timers/promises';
21
+ import { pathToFileURL } from 'node:url';
22
+
23
+ const TAG_RE = /^mobile-v(\d+)\.(\d+)\.(\d+)$/;
24
+
25
+ // Everything the APK and the iOS build are made from. The app bundles one
26
+ // desktop module (apps/mobile/src/main.ts imports shared/native-app.ts, which
27
+ // imports nothing). Relay changes are not listed: the relay deploys on its
28
+ // own with every release and the app binary does not embed it.
29
+ export const MOBILE_RELEASE_PATHS = [
30
+ 'apps/mobile',
31
+ ':(exclude,glob)apps/mobile/**/*.md',
32
+ 'apps/desktop/src/shared/native-app.ts',
33
+ '.github/workflows/mobile-android.yml',
34
+ '.github/workflows/mobile-ios.yml',
35
+ ];
36
+
37
+ export function parseMobileTag(tag) {
38
+ const match = TAG_RE.exec(String(tag ?? '').trim());
39
+ return match ? match.slice(1).map(Number) : null;
40
+ }
41
+
42
+ export function compareMobileTags(left, right) {
43
+ const a = parseMobileTag(left);
44
+ const b = parseMobileTag(right);
45
+ for (let index = 0; index < 3; index += 1) {
46
+ if (a[index] !== b[index]) return a[index] - b[index];
47
+ }
48
+ return 0;
49
+ }
50
+
51
+ export function latestMobileTag(tags) {
52
+ const valid = tags.map((tag) => String(tag).trim()).filter((tag) => parseMobileTag(tag));
53
+ return valid.sort(compareMobileTags).at(-1) ?? null;
54
+ }
55
+
56
+ export function nextMobileVersion(tag, bump = 'patch') {
57
+ const parts = parseMobileTag(tag);
58
+ if (!parts) throw new Error(`invalid mobile release tag: ${tag}`);
59
+ const [major, minor, patch] = parts;
60
+ if (bump === 'minor') return `${major}.${minor + 1}.0`;
61
+ if (bump === 'patch') return `${major}.${minor}.${patch + 1}`;
62
+ throw new Error(`invalid mobile version bump: ${bump}`);
63
+ }
64
+
65
+ /** `changedSince(tag)` answers whether a mobile path changed between tag and HEAD. */
66
+ export function planMobileRelease({ mode = 'auto', bump = 'patch', tags, changedSince }) {
67
+ if (!['auto', 'force', 'skip'].includes(mode)) throw new Error(`invalid mobile mode: ${mode}`);
68
+ if (!['patch', 'minor'].includes(bump)) throw new Error(`invalid mobile version bump: ${bump}`);
69
+ const base = latestMobileTag(tags);
70
+ const none = (reason) => ({ release: false, version: '', tag: '', base: base ?? '', reason });
71
+ if (mode === 'skip') return none('skipped by the mobile input');
72
+ // Versions continue from the newest tag; without one there is nothing to
73
+ // continue from, and guessing could overwrite a published release.
74
+ if (!base) return none('no mobile-v* tag yet: push the first one by hand');
75
+ const changed = changedSince(base);
76
+ if (mode === 'auto' && !changed) return none(`no mobile source changed since ${base}`);
77
+ const version = nextMobileVersion(base, bump);
78
+ return {
79
+ release: true,
80
+ version,
81
+ tag: `mobile-v${version}`,
82
+ base,
83
+ reason: changed ? `mobile sources changed since ${base}` : 'forced by the mobile input',
84
+ };
85
+ }
86
+
87
+ export function mobilePathsChangedSince(tag, { cwd } = {}) {
88
+ const result = spawnSync('git', ['diff', '--quiet', `${tag}..HEAD`, '--', ...MOBILE_RELEASE_PATHS], { cwd });
89
+ if (result.status === 0) return false;
90
+ if (result.status === 1) return true;
91
+ throw new Error(`git diff failed for ${tag}: ${result.stderr?.toString() || result.status}`);
92
+ }
93
+
94
+ /** Published mobile releases that carry an APK, newest version first. */
95
+ export function androidReleaseCandidates(releases) {
96
+ return releases
97
+ .filter((release) => !release.draft && parseMobileTag(release.tag_name))
98
+ .map((release) => ({
99
+ tag: release.tag_name,
100
+ url: release.html_url,
101
+ apkUrl: release.assets?.find((asset) => /\.apk$/i.test(asset.name || ''))?.browser_download_url,
102
+ }))
103
+ .filter((release) => release.url && release.apkUrl)
104
+ .sort((left, right) => compareMobileTags(right.tag, left.tag));
105
+ }
106
+
107
+ async function listReleases(repository, { token, fetchImpl }) {
108
+ const releases = [];
109
+ for (let page = 1; ; page += 1) {
110
+ const response = await fetchImpl(`https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}`, {
111
+ headers: {
112
+ Accept: 'application/vnd.github+json',
113
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
114
+ },
115
+ });
116
+ if (!response.ok) throw new Error(`listing releases failed: HTTP ${response.status}`);
117
+ const batch = await response.json();
118
+ releases.push(...batch);
119
+ if (batch.length < 100) return releases;
120
+ }
121
+ }
122
+
123
+ async function downloadable(url, { fetchImpl, attempts, delayMs }) {
124
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
125
+ try {
126
+ const response = await fetchImpl(url, { method: 'HEAD', redirect: 'follow' });
127
+ if (response.ok) return true;
128
+ } catch {
129
+ /* retried below */
130
+ }
131
+ if (attempt < attempts) await delay(delayMs);
132
+ }
133
+ return false;
134
+ }
135
+
136
+ export async function resolveAndroidLink({
137
+ repository,
138
+ token = '',
139
+ requiredTag = '',
140
+ fetchImpl = fetch,
141
+ attempts = 3,
142
+ delayMs = 5000,
143
+ }) {
144
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository || '')) {
145
+ throw new Error(`invalid repository: ${repository}`);
146
+ }
147
+ if (requiredTag && !parseMobileTag(requiredTag)) throw new Error(`invalid mobile release tag: ${requiredTag}`);
148
+ const candidates = androidReleaseCandidates(await listReleases(repository, { token, fetchImpl }));
149
+ for (const candidate of requiredTag ? candidates.filter((item) => item.tag === requiredTag) : candidates) {
150
+ if (await downloadable(candidate.apkUrl, { fetchImpl, attempts, delayMs })) {
151
+ return { url: candidate.url, tag: candidate.tag };
152
+ }
153
+ if (!requiredTag) console.warn(`::warning::${candidate.tag} APK is not downloadable; trying an older release.`);
154
+ }
155
+ if (requiredTag) throw new Error(`${requiredTag} is not a published release with a downloadable APK`);
156
+ return { url: `https://github.com/${repository}/releases`, tag: '' };
157
+ }
158
+
159
+ function writeOutputs(values) {
160
+ if (process.env.GITHUB_OUTPUT) {
161
+ appendFileSync(
162
+ process.env.GITHUB_OUTPUT,
163
+ Object.entries(values)
164
+ .map(([key, value]) => `${key}=${value}\n`)
165
+ .join('')
166
+ );
167
+ }
168
+ console.log(JSON.stringify(values, null, 2));
169
+ }
170
+
171
+ async function main([command, ...args]) {
172
+ if (command === 'plan') {
173
+ const tags = execFileSync('git', ['tag', '--list', 'mobile-v*'], { encoding: 'utf8' }).split(/\r?\n/);
174
+ const plan = planMobileRelease({
175
+ mode: process.env.MOBILE_MODE || 'auto',
176
+ bump: process.env.MOBILE_BUMP || 'patch',
177
+ tags,
178
+ changedSince: (tag) => mobilePathsChangedSince(tag),
179
+ });
180
+ writeOutputs({
181
+ mobile_release: String(plan.release),
182
+ mobile_version: plan.version,
183
+ mobile_tag: plan.tag,
184
+ mobile_base: plan.base,
185
+ mobile_reason: plan.reason,
186
+ });
187
+ return;
188
+ }
189
+ if (command === 'link') {
190
+ const requiredTag = args.find((arg) => arg.startsWith('--require='))?.slice('--require='.length) || '';
191
+ writeOutputs(
192
+ await resolveAndroidLink({
193
+ repository: process.env.GITHUB_REPOSITORY,
194
+ token: process.env.GH_TOKEN || '',
195
+ requiredTag,
196
+ // Right after publishing, the asset CDN can lag a few seconds.
197
+ attempts: requiredTag ? 6 : 3,
198
+ })
199
+ );
200
+ return;
201
+ }
202
+ throw new Error('usage: node scripts/mobile-release.mjs plan | link [--require=mobile-vX.Y.Z]');
203
+ }
204
+
205
+ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
206
+ main(process.argv.slice(2)).catch((error) => {
207
+ console.error(`::error::${error.message}`);
208
+ process.exitCode = 1;
209
+ });
210
+ }
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env node
2
+ // Final job of Deploy: verify the published app release end to end.
3
+ //
4
+ // - GitHub Latest is the new app tag
5
+ // - the release asset set matches the asset contract (release.yml
6
+ // "Verify complete hidden release"), and every url/sha512 listed in
7
+ // latest.yml and latest-mac.yml matches the uploaded assets
8
+ // - mixdog@<version> is resolvable on npm (retried while it propagates)
9
+ // - production relay /app answers 200 and links an existing, published
10
+ // mobile-v* release
11
+ // - the effort-judge manifest release exists, is published, and is not Latest
12
+ //
13
+ // Env: GITHUB_REPOSITORY, RELEASE_TAG (vX.Y.Z), RELAY_DOMAIN, GH_TOKEN.
14
+
15
+ import { createHash } from 'node:crypto';
16
+ import { appendFileSync, readFileSync } from 'node:fs';
17
+ import { pathToFileURL } from 'node:url';
18
+ import { parseMacFeed } from './merge-mac-update-feed.mjs';
19
+ import { APP_TAG_RE, currentLatestTag, gh, isHttp404 } from './release-guard.mjs';
20
+
21
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
22
+
23
+ // Keep in step with the per-platform `requirements` in
24
+ // .github/workflows/release.yml (a unit test compares both). The Linux update
25
+ // feeds (latest-linux*.yml) are not parsed here, only required to exist.
26
+ export const ASSET_CONTRACT = Object.freeze([
27
+ 'latest-linux-arm64.yml',
28
+ 'latest-linux.yml',
29
+ 'latest-mac.yml',
30
+ 'latest.yml',
31
+ 'mixdog-desktop-linux-amd64.deb',
32
+ 'mixdog-desktop-linux-arm64.AppImage',
33
+ 'mixdog-desktop-linux-arm64.deb',
34
+ 'mixdog-desktop-linux-x86_64.AppImage',
35
+ 'mixdog-desktop-mac-arm64.dmg',
36
+ 'mixdog-desktop-mac-arm64.zip',
37
+ 'mixdog-desktop-mac-arm64.zip.blockmap',
38
+ 'mixdog-desktop-mac-x64.dmg',
39
+ 'mixdog-desktop-mac-x64.zip',
40
+ 'mixdog-desktop-mac-x64.zip.blockmap',
41
+ 'mixdog-desktop-win-x64.exe',
42
+ 'mixdog-desktop-win-x64.exe.blockmap',
43
+ ]);
44
+
45
+ const MAC_ZIPS = ['mixdog-desktop-mac-arm64.zip', 'mixdog-desktop-mac-x64.zip'];
46
+
47
+ export function checkLatest(tag, latestTag) {
48
+ return latestTag === tag ? [] : [`GitHub Latest is ${latestTag ?? 'none'}, expected ${tag}`];
49
+ }
50
+
51
+ export function checkAssetSet(assets) {
52
+ const problems = [];
53
+ const names = assets.map((asset) => asset.name);
54
+ for (const name of ASSET_CONTRACT) {
55
+ if (!names.includes(name)) problems.push(`missing release asset ${name}`);
56
+ }
57
+ for (const name of names) {
58
+ if (!ASSET_CONTRACT.includes(name)) problems.push(`unexpected release asset ${name}`);
59
+ }
60
+ if (new Set(names).size !== names.length) problems.push('duplicate release asset names');
61
+ for (const asset of assets) {
62
+ if (asset.state !== 'uploaded' || !(asset.size > 0)) problems.push(`incomplete release asset ${asset.name}`);
63
+ }
64
+ return problems;
65
+ }
66
+
67
+ /**
68
+ * Check one electron-builder feed against the uploaded assets: version, that
69
+ * every listed file is an uploaded asset of the listed size, and that its
70
+ * sha512 is the hash of the uploaded bytes. `hashAsset(asset)` returns the
71
+ * base64 sha512 of an asset's content.
72
+ */
73
+ export async function checkFeed({ text, label, version, assets, hashAsset, requiredFiles = [] }) {
74
+ let feed;
75
+ try {
76
+ feed = parseMacFeed(text, label);
77
+ } catch (error) {
78
+ return [error.message];
79
+ }
80
+ const problems = [];
81
+ if (feed.version !== version) {
82
+ problems.push(`${label}: version ${feed.version}, expected ${version}`);
83
+ }
84
+ const field = (file, key) =>
85
+ file.lines.map((line) => new RegExp(`^ +(?:- )?${key}: (\\S+)$`).exec(line)?.[1]).find(Boolean);
86
+ const byUrl = new Map(feed.files.map((file) => [file.url, file]));
87
+ for (const name of requiredFiles) {
88
+ if (!byUrl.has(name)) problems.push(`${label}: files[] does not list ${name}`);
89
+ }
90
+ const hashes = new Map();
91
+ for (const file of feed.files) {
92
+ const asset = assets.find((candidate) => candidate.name === file.url);
93
+ if (!asset) {
94
+ problems.push(`${label}: ${file.url} is not an uploaded asset`);
95
+ continue;
96
+ }
97
+ const size = field(file, 'size');
98
+ if (size === undefined || Number(size) !== asset.size) {
99
+ problems.push(`${label}: ${file.url} size ${size} != uploaded ${asset.size}`);
100
+ }
101
+ const sha512 = field(file, 'sha512');
102
+ const actual = await hashAsset(asset);
103
+ hashes.set(file.url, actual);
104
+ if (sha512 !== actual) problems.push(`${label}: ${file.url} sha512 does not match the uploaded asset`);
105
+ }
106
+ // Top-level path/sha512 is what pre-files[] clients read; it must agree too.
107
+ const topPath = /^path: (\S+)$/.exec(feed.top.path)?.[1];
108
+ const topSha = /^sha512: (\S+)$/.exec(feed.top.sha512)?.[1];
109
+ if (!byUrl.has(topPath)) problems.push(`${label}: path ${topPath} is not listed in files[]`);
110
+ else if (hashes.has(topPath) && hashes.get(topPath) !== topSha) {
111
+ problems.push(`${label}: top-level sha512 does not match ${topPath}`);
112
+ }
113
+ return problems;
114
+ }
115
+
116
+ export async function waitForNpm({
117
+ name,
118
+ version,
119
+ fetchFn = fetch,
120
+ sleep = defaultSleep,
121
+ budgetMs = 10 * 60 * 1000,
122
+ firstDelayMs = 5000,
123
+ maxDelayMs = 60_000,
124
+ }) {
125
+ let waited = 0;
126
+ let delay = firstDelayMs;
127
+ let last = 'not queried';
128
+ for (;;) {
129
+ try {
130
+ const response = await fetchFn(`https://registry.npmjs.org/${name}/${version}`);
131
+ if (response.ok && (await response.json()).version === version) return [];
132
+ last = `HTTP ${response.status}`;
133
+ } catch (error) {
134
+ last = error.message;
135
+ }
136
+ if (waited >= budgetMs)
137
+ return [`${name}@${version} not resolvable on npm after ${Math.round(waited / 1000)}s (${last})`];
138
+ const step = Math.min(delay, budgetMs - waited);
139
+ await sleep(step);
140
+ waited += step;
141
+ delay = Math.min(delay * 2, maxDelayMs);
142
+ }
143
+ }
144
+
145
+ const ANDROID_LINK_RE =
146
+ /https:\/\/github\.com\/[^"'\s<>]+\/releases\/(?:tag|download)\/(mobile-v\d+\.\d+\.\d+)[^"'\s<>]*/;
147
+
148
+ /**
149
+ * `getRelease(tag)` returns the release object or null when it does not exist.
150
+ * `mobileVersion` is the Android version this deploy tried to ship (empty for
151
+ * none). When that release failed, /app keeps linking the previous good
152
+ * mobile-v* release (or the releases list); that is a warning, not an error.
153
+ * A link to a missing or draft release stays an error.
154
+ * Returns { problems, warnings }.
155
+ */
156
+ export async function checkRelayApp({ domain, fetchFn = fetch, getRelease, mobileVersion = '' }) {
157
+ const page = await fetchFn(`https://${domain}/app`);
158
+ if (page.status !== 200)
159
+ return { problems: [`https://${domain}/app answered ${page.status}, expected 200`], warnings: [] };
160
+ const match = ANDROID_LINK_RE.exec(await page.text());
161
+ const wanted = mobileVersion ? `mobile-v${mobileVersion}` : '';
162
+ if (!match) {
163
+ return wanted
164
+ ? {
165
+ problems: [],
166
+ warnings: [`/app links no mobile-v* release; ${wanted} was not shipped (previous good release kept)`],
167
+ }
168
+ : { problems: [`https://${domain}/app has no link to a mobile-v* release`], warnings: [] };
169
+ }
170
+ const [url, tag] = match;
171
+ const release = getRelease(tag);
172
+ if (!release) return { problems: [`/app links ${tag}, which is not an existing release`], warnings: [] };
173
+ if (release.draft) return { problems: [`/app links ${tag}, which is still a draft`], warnings: [] };
174
+ const resolved = await fetchFn(url, { redirect: 'follow' });
175
+ if (!resolved.ok) return { problems: [`/app Android link ${url} answered ${resolved.status}`], warnings: [] };
176
+ const warnings =
177
+ wanted && tag !== wanted
178
+ ? [
179
+ `/app links ${tag} instead of ${wanted}; the Android release failed or is not yet published (previous good release kept)`,
180
+ ]
181
+ : [];
182
+ return { problems: [], warnings };
183
+ }
184
+
185
+ export function checkEffortJudge({ manifest, latestTag, getRelease }) {
186
+ const tag = manifest.release;
187
+ const release = getRelease(tag);
188
+ if (!release) return [`effort-judge release ${tag} does not exist`];
189
+ const problems = [];
190
+ if (release.draft) problems.push(`effort-judge release ${tag} is a draft`);
191
+ if (latestTag === tag) problems.push(`effort-judge release ${tag} is GitHub Latest`);
192
+ for (const [name, file] of Object.entries(manifest.files)) {
193
+ const asset = (release.assets ?? []).find((candidate) => candidate.name === name);
194
+ if (!asset) problems.push(`effort-judge release ${tag} lacks ${name}`);
195
+ else if (asset.size !== file.size)
196
+ problems.push(`effort-judge ${name} size ${asset.size} != manifest ${file.size}`);
197
+ }
198
+ return problems;
199
+ }
200
+
201
+ /** The release for `tag`, or null only on an explicit HTTP 404. Any other failure throws. */
202
+ export function getReleaseByTag(repo, tag, run = gh) {
203
+ try {
204
+ return JSON.parse(run(['api', `repos/${repo}/releases/tags/${tag}`]));
205
+ } catch (error) {
206
+ if (isHttp404(error)) return null;
207
+ throw error;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Was the app release published? A missing release (`fetchRelease` returns
213
+ * null) or a draft means "not published" and the checks are skipped. Any
214
+ * other failure (auth, transport, 5xx) is retried with backoff and then
215
+ * rethrown, so an API outage fails the job instead of silently skipping
216
+ * verification of a release that may be public.
217
+ */
218
+ export async function detectPublished({
219
+ fetchRelease,
220
+ sleep = defaultSleep,
221
+ attempts = 5,
222
+ firstDelayMs = 2000,
223
+ }) {
224
+ let delay = firstDelayMs;
225
+ for (let attempt = 1; ; attempt++) {
226
+ try {
227
+ const release = fetchRelease();
228
+ return Boolean(release) && release.draft === false;
229
+ } catch (error) {
230
+ if (attempt >= attempts) {
231
+ throw new Error(
232
+ `could not determine whether the release is published after ${attempts} attempts: ${error.message}`
233
+ );
234
+ }
235
+ await sleep(delay);
236
+ delay *= 2;
237
+ }
238
+ }
239
+ }
240
+
241
+ async function sha512Base64(url) {
242
+ const response = await fetch(url, { redirect: 'follow' });
243
+ if (!response.ok) throw new Error(`download ${url} answered ${response.status}`);
244
+ const hash = createHash('sha512');
245
+ for await (const chunk of response.body) hash.update(chunk);
246
+ return hash.digest('base64');
247
+ }
248
+
249
+ async function main() {
250
+ const repo = process.env.GITHUB_REPOSITORY;
251
+ const tag = process.env.RELEASE_TAG;
252
+ const domain = process.env.RELAY_DOMAIN;
253
+ if (!repo || !APP_TAG_RE.test(tag ?? '') || !domain) {
254
+ throw new Error('GITHUB_REPOSITORY, RELEASE_TAG (vX.Y.Z) and RELAY_DOMAIN are required');
255
+ }
256
+ const version = tag.slice(1);
257
+ const getRelease = (releaseTag) => getReleaseByTag(repo, releaseTag);
258
+ const release = getRelease(tag);
259
+ const assets = release?.assets ?? [];
260
+ const latestTag = currentLatestTag(repo);
261
+ const hashed = new Map();
262
+ const hashAsset = (asset) => {
263
+ if (!hashed.has(asset.name)) hashed.set(asset.name, sha512Base64(asset.browser_download_url));
264
+ return hashed.get(asset.name);
265
+ };
266
+ const feedText = async (name) => {
267
+ const asset = assets.find((candidate) => candidate.name === name);
268
+ if (!asset) return null;
269
+ const response = await fetch(asset.browser_download_url, { redirect: 'follow' });
270
+ return response.ok ? response.text() : null;
271
+ };
272
+ const feed = async (name, requiredFiles) => {
273
+ const text = await feedText(name);
274
+ if (text === null) return [`${name} could not be downloaded from ${tag}`];
275
+ return checkFeed({ text, label: name, version, assets, hashAsset, requiredFiles });
276
+ };
277
+
278
+ const sections = [
279
+ [
280
+ 'GitHub Latest',
281
+ async () => (release && !release.draft ? checkLatest(tag, latestTag) : [`${tag} is not a published release`]),
282
+ ],
283
+ ['Release assets', async () => checkAssetSet(assets)],
284
+ ['latest.yml', () => feed('latest.yml', ['mixdog-desktop-win-x64.exe'])],
285
+ ['latest-mac.yml', () => feed('latest-mac.yml', MAC_ZIPS)],
286
+ ['Relay /app', () => checkRelayApp({ domain, getRelease, mobileVersion: process.env.MOBILE_VERSION ?? '' })],
287
+ [
288
+ 'effort-judge release',
289
+ async () =>
290
+ checkEffortJudge({
291
+ manifest: JSON.parse(readFileSync('src/runtime/effort-judge/model-manifest.json', 'utf8')),
292
+ latestTag,
293
+ getRelease,
294
+ }),
295
+ ],
296
+ ['npm', () => waitForNpm({ name: 'mixdog', version })],
297
+ ];
298
+ const results = [];
299
+ for (const [name, run] of sections) {
300
+ try {
301
+ const outcome = await run();
302
+ results.push(
303
+ Array.isArray(outcome)
304
+ ? { name, problems: outcome, warnings: [] }
305
+ : { name, problems: outcome.problems, warnings: outcome.warnings }
306
+ );
307
+ } catch (error) {
308
+ results.push({ name, problems: [`check failed: ${error.message}`], warnings: [] });
309
+ }
310
+ }
311
+
312
+ const lines = [`## Post-release check for ${tag}`, ''];
313
+ for (const { name, problems, warnings } of results) {
314
+ let status = 'ok';
315
+ if (problems.length) status = 'FAIL';
316
+ else if (warnings.length) status = 'WARN';
317
+ lines.push(`- ${status} ${name}`);
318
+ for (const problem of problems) lines.push(` - ${problem}`);
319
+ for (const warning of warnings) {
320
+ lines.push(` - warning: ${warning}`);
321
+ console.log(`::warning title=Post-release ${name}::${warning}`);
322
+ }
323
+ }
324
+ const text = `${lines.join('\n')}\n`;
325
+ console.log(text);
326
+ if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, text);
327
+ return results.some(({ problems }) => problems.length) ? 1 : 0;
328
+ }
329
+
330
+ // `detect-published`: write published=true|false to GITHUB_OUTPUT. Exits 1
331
+ // (job fails) when the GitHub API cannot give a definite answer.
332
+ async function detectPublishedMain() {
333
+ const repo = process.env.GITHUB_REPOSITORY;
334
+ const tag = process.env.RELEASE_TAG;
335
+ if (!repo || !APP_TAG_RE.test(tag ?? '')) {
336
+ throw new Error('GITHUB_REPOSITORY and RELEASE_TAG (vX.Y.Z) are required');
337
+ }
338
+ const published = await detectPublished({ fetchRelease: () => getReleaseByTag(repo, tag) });
339
+ if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `published=${published}\n`);
340
+ const result = process.env.RELEASE_RESULT ?? 'unknown';
341
+ if (published) console.log(`${tag} is published (release job: ${result}); running the post-release checks.`);
342
+ else console.log(`::notice title=Post-release checks skipped::${tag} was not published (release job: ${result}).`);
343
+ return 0;
344
+ }
345
+
346
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
347
+ try {
348
+ process.exit(process.argv[2] === 'detect-published' ? await detectPublishedMain() : await main());
349
+ } catch (error) {
350
+ console.log(`::error title=Post-release check::${error.message}`);
351
+ process.exit(1);
352
+ }
353
+ }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ // Create or publish a NON-app release (effort-judge, voice, graph, spawn,
3
+ // mobile, ...) without ever moving GitHub's "Latest" pointer, which belongs to
4
+ // the newest app release (vX.Y.Z) alone.
5
+ //
6
+ // publish-asset-release.mjs create <tag> [gh release create options/files...]
7
+ // publish-asset-release.mjs publish <tag> # draft -> published
8
+ //
9
+ // Always passes --latest=false, and fails if Latest changed across the call.
10
+
11
+ import { pathToFileURL } from 'node:url';
12
+ import { APP_TAG_RE, currentLatestTag, gh } from './release-guard.mjs';
13
+
14
+ export function buildArgs(action, tag, extra = [], repo) {
15
+ if (APP_TAG_RE.test(tag)) throw new Error(`${tag} is an app release tag; asset releases must not use it`);
16
+ if (extra.some((arg) => /^--latest(=|$)/.test(arg))) {
17
+ throw new Error('--latest is set by this script; do not pass it');
18
+ }
19
+ const scope = repo ? ['--repo', repo] : [];
20
+ if (action === 'create') return ['release', 'create', tag, ...extra, ...scope, '--latest=false'];
21
+ if (action === 'publish') return ['release', 'edit', tag, ...scope, '--draft=false', '--latest=false'];
22
+ throw new Error(`unknown action ${action} (expected create|publish)`);
23
+ }
24
+
25
+ export function publishAssetRelease({ action, tag, extra = [], repo, run = gh }) {
26
+ const args = buildArgs(action, tag, extra, repo);
27
+ const before = currentLatestTag(repo, run);
28
+ run(args);
29
+ const after = currentLatestTag(repo, run);
30
+ if (after !== before) {
31
+ throw new Error(`Latest moved from ${before ?? 'none'} to ${after ?? 'none'} while ${action} ${tag}`);
32
+ }
33
+ return before;
34
+ }
35
+
36
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
37
+ const [action, tag, ...extra] = process.argv.slice(2);
38
+ if (!action || !tag) {
39
+ throw new Error('usage: publish-asset-release.mjs <create|publish> <tag> [gh release create args...]');
40
+ }
41
+ const latest = publishAssetRelease({ action, tag, extra, repo: process.env.GITHUB_REPOSITORY });
42
+ console.log(`${action} ${tag}: Latest unchanged (${latest ?? 'none'}).`);
43
+ }
@@ -0,0 +1,63 @@
1
+ // Shared helpers for the release automation guards: latest-guard.yml,
2
+ // publish-asset-release.mjs and post-release-check.mjs.
3
+ //
4
+ // GitHub's "Latest" release is what electron-updater follows (latest.yml /
5
+ // latest-mac.yml), so it must always be the newest published desktop app
6
+ // release (vX.Y.Z). Asset releases (effort-judge-*, voice-*, graph-v*, ...)
7
+ // and mobile-v* releases must never take it.
8
+
9
+ import { execFileSync } from 'node:child_process';
10
+
11
+ export const APP_TAG_RE = /^v\d+\.\d+\.\d+$/;
12
+
13
+ /** Numeric semver comparison of two vX.Y.Z tags (or X.Y.Z versions). */
14
+ export function compareSemver(a, b) {
15
+ const left = String(a).replace(/^v/, '').split('.').map(Number);
16
+ const right = String(b).replace(/^v/, '').split('.').map(Number);
17
+ for (let index = 0; index < 3; index++) {
18
+ if (left[index] !== right[index]) return left[index] - right[index];
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ /** The newest non-draft, non-prerelease app release, or null. */
24
+ export function selectLatestAppRelease(releases) {
25
+ const apps = releases.filter(
26
+ (release) => !release.draft && !release.prerelease && APP_TAG_RE.test(release.tag_name || '')
27
+ );
28
+ apps.sort((a, b) => compareSemver(b.tag_name, a.tag_name));
29
+ return apps[0] ?? null;
30
+ }
31
+
32
+ /** Run `gh` and return stdout. Throws with gh's stderr on failure. */
33
+ export function gh(args) {
34
+ return execFileSync('gh', args, {
35
+ encoding: 'utf8',
36
+ maxBuffer: 256 * 1024 * 1024,
37
+ stdio: ['ignore', 'pipe', 'pipe'],
38
+ });
39
+ }
40
+
41
+ export function ghJson(args, run = gh) {
42
+ return JSON.parse(run(args));
43
+ }
44
+
45
+ /** All releases of a repo, newest first as the API returns them. */
46
+ export function listReleases(repo, run = gh) {
47
+ return ghJson(['api', '--paginate', '--slurp', `repos/${repo}/releases?per_page=100`], run).flat();
48
+ }
49
+
50
+ /** True only for an explicit HTTP 404 from `gh api`; "not found" wording alone (e.g. behind a 500) is not absence. */
51
+ export function isHttp404(error) {
52
+ return /\bHTTP 404\b/.test(`${error.stderr ?? ''}${error.message ?? ''}`);
53
+ }
54
+
55
+ /** Tag of the repo's current "Latest" release, or null when there is none. */
56
+ export function currentLatestTag(repo, run = gh) {
57
+ try {
58
+ return run(['api', `repos/${repo}/releases/latest`, '--jq', '.tag_name']).trim() || null;
59
+ } catch (error) {
60
+ if (isHttp404(error)) return null;
61
+ throw error;
62
+ }
63
+ }