opengstack 0.13.7 → 0.13.9

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 (135) hide show
  1. package/bin/opengstack.js +35 -90
  2. package/package.json +2 -3
  3. package/scripts/install-skills.js +47 -58
  4. package/skills/browse/bin/find-browse +21 -0
  5. package/skills/browse/bin/remote-slug +14 -0
  6. package/skills/browse/scripts/build-node-server.sh +48 -0
  7. package/skills/browse/src/activity.ts +208 -0
  8. package/skills/browse/src/browser-manager.ts +959 -0
  9. package/skills/browse/src/buffers.ts +137 -0
  10. package/skills/browse/src/bun-polyfill.cjs +109 -0
  11. package/skills/browse/src/cli.ts +678 -0
  12. package/skills/browse/src/commands.ts +128 -0
  13. package/skills/browse/src/config.ts +150 -0
  14. package/skills/browse/src/cookie-import-browser.ts +625 -0
  15. package/skills/browse/src/cookie-picker-routes.ts +230 -0
  16. package/skills/browse/src/cookie-picker-ui.ts +688 -0
  17. package/skills/browse/src/find-browse.ts +61 -0
  18. package/skills/browse/src/meta-commands.ts +550 -0
  19. package/skills/browse/src/platform.ts +17 -0
  20. package/skills/browse/src/read-commands.ts +358 -0
  21. package/skills/browse/src/server.ts +1192 -0
  22. package/skills/browse/src/sidebar-agent.ts +280 -0
  23. package/skills/browse/src/sidebar-utils.ts +21 -0
  24. package/skills/browse/src/snapshot.ts +407 -0
  25. package/skills/browse/src/url-validation.ts +95 -0
  26. package/skills/browse/src/write-commands.ts +364 -0
  27. package/skills/browse/test/activity.test.ts +120 -0
  28. package/skills/browse/test/adversarial-security.test.ts +32 -0
  29. package/skills/browse/test/browser-manager-unit.test.ts +17 -0
  30. package/skills/browse/test/bun-polyfill.test.ts +72 -0
  31. package/skills/browse/test/commands.test.ts +2075 -0
  32. package/skills/browse/test/compare-board.test.ts +342 -0
  33. package/skills/browse/test/config.test.ts +316 -0
  34. package/skills/browse/test/cookie-import-browser.test.ts +519 -0
  35. package/skills/browse/test/cookie-picker-routes.test.ts +260 -0
  36. package/skills/browse/test/file-drop.test.ts +271 -0
  37. package/skills/browse/test/find-browse.test.ts +50 -0
  38. package/skills/browse/test/findport.test.ts +191 -0
  39. package/skills/browse/test/fixtures/basic.html +33 -0
  40. package/skills/browse/test/fixtures/cursor-interactive.html +22 -0
  41. package/skills/browse/test/fixtures/dialog.html +15 -0
  42. package/skills/browse/test/fixtures/empty.html +2 -0
  43. package/skills/browse/test/fixtures/forms.html +55 -0
  44. package/skills/browse/test/fixtures/iframe.html +30 -0
  45. package/skills/browse/test/fixtures/network-idle.html +30 -0
  46. package/skills/browse/test/fixtures/qa-eval-checkout.html +108 -0
  47. package/skills/browse/test/fixtures/qa-eval-spa.html +98 -0
  48. package/skills/browse/test/fixtures/qa-eval.html +51 -0
  49. package/skills/browse/test/fixtures/responsive.html +49 -0
  50. package/skills/browse/test/fixtures/snapshot.html +55 -0
  51. package/skills/browse/test/fixtures/spa.html +24 -0
  52. package/skills/browse/test/fixtures/states.html +17 -0
  53. package/skills/browse/test/fixtures/upload.html +25 -0
  54. package/skills/browse/test/gstack-config.test.ts +138 -0
  55. package/skills/browse/test/gstack-update-check.test.ts +514 -0
  56. package/skills/browse/test/handoff.test.ts +235 -0
  57. package/skills/browse/test/path-validation.test.ts +91 -0
  58. package/skills/browse/test/platform.test.ts +37 -0
  59. package/skills/browse/test/server-auth.test.ts +65 -0
  60. package/skills/browse/test/sidebar-agent-roundtrip.test.ts +226 -0
  61. package/skills/browse/test/sidebar-agent.test.ts +199 -0
  62. package/skills/browse/test/sidebar-integration.test.ts +320 -0
  63. package/skills/browse/test/sidebar-unit.test.ts +96 -0
  64. package/skills/browse/test/snapshot.test.ts +467 -0
  65. package/skills/browse/test/state-ttl.test.ts +35 -0
  66. package/skills/browse/test/test-server.ts +57 -0
  67. package/skills/browse/test/url-validation.test.ts +72 -0
  68. package/skills/browse/test/watch.test.ts +129 -0
  69. package/skills/careful/bin/check-careful.sh +112 -0
  70. package/skills/cso/ACKNOWLEDGEMENTS.md +14 -0
  71. package/skills/freeze/bin/check-freeze.sh +79 -0
  72. package/skills/qa/references/issue-taxonomy.md +85 -0
  73. package/skills/qa/templates/qa-report-template.md +126 -0
  74. package/skills/review/TODOS-format.md +62 -0
  75. package/skills/review/checklist.md +220 -0
  76. package/skills/review/design-checklist.md +132 -0
  77. package/skills/review/greptile-triage.md +220 -0
  78. /package/{autoplan → skills/autoplan}/SKILL.md +0 -0
  79. /package/{autoplan → skills/autoplan}/SKILL.md.tmpl +0 -0
  80. /package/{benchmark → skills/benchmark}/SKILL.md +0 -0
  81. /package/{benchmark → skills/benchmark}/SKILL.md.tmpl +0 -0
  82. /package/{browse → skills/browse}/SKILL.md +0 -0
  83. /package/{browse → skills/browse}/SKILL.md.tmpl +0 -0
  84. /package/{canary → skills/canary}/SKILL.md +0 -0
  85. /package/{canary → skills/canary}/SKILL.md.tmpl +0 -0
  86. /package/{careful → skills/careful}/SKILL.md +0 -0
  87. /package/{careful → skills/careful}/SKILL.md.tmpl +0 -0
  88. /package/{codex → skills/codex}/SKILL.md +0 -0
  89. /package/{codex → skills/codex}/SKILL.md.tmpl +0 -0
  90. /package/{connect-chrome → skills/connect-chrome}/SKILL.md +0 -0
  91. /package/{connect-chrome → skills/connect-chrome}/SKILL.md.tmpl +0 -0
  92. /package/{cso → skills/cso}/SKILL.md +0 -0
  93. /package/{cso → skills/cso}/SKILL.md.tmpl +0 -0
  94. /package/{design-consultation → skills/design-consultation}/SKILL.md +0 -0
  95. /package/{design-consultation → skills/design-consultation}/SKILL.md.tmpl +0 -0
  96. /package/{design-review → skills/design-review}/SKILL.md +0 -0
  97. /package/{design-review → skills/design-review}/SKILL.md.tmpl +0 -0
  98. /package/{design-shotgun → skills/design-shotgun}/SKILL.md +0 -0
  99. /package/{design-shotgun → skills/design-shotgun}/SKILL.md.tmpl +0 -0
  100. /package/{document-release → skills/document-release}/SKILL.md +0 -0
  101. /package/{document-release → skills/document-release}/SKILL.md.tmpl +0 -0
  102. /package/{freeze → skills/freeze}/SKILL.md +0 -0
  103. /package/{freeze → skills/freeze}/SKILL.md.tmpl +0 -0
  104. /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md +0 -0
  105. /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md.tmpl +0 -0
  106. /package/{guard → skills/guard}/SKILL.md +0 -0
  107. /package/{guard → skills/guard}/SKILL.md.tmpl +0 -0
  108. /package/{investigate → skills/investigate}/SKILL.md +0 -0
  109. /package/{investigate → skills/investigate}/SKILL.md.tmpl +0 -0
  110. /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md +0 -0
  111. /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md.tmpl +0 -0
  112. /package/{office-hours → skills/office-hours}/SKILL.md +0 -0
  113. /package/{office-hours → skills/office-hours}/SKILL.md.tmpl +0 -0
  114. /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md +0 -0
  115. /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md.tmpl +0 -0
  116. /package/{plan-design-review → skills/plan-design-review}/SKILL.md +0 -0
  117. /package/{plan-design-review → skills/plan-design-review}/SKILL.md.tmpl +0 -0
  118. /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md +0 -0
  119. /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md.tmpl +0 -0
  120. /package/{qa → skills/qa}/SKILL.md +0 -0
  121. /package/{qa → skills/qa}/SKILL.md.tmpl +0 -0
  122. /package/{qa-only → skills/qa-only}/SKILL.md +0 -0
  123. /package/{qa-only → skills/qa-only}/SKILL.md.tmpl +0 -0
  124. /package/{retro → skills/retro}/SKILL.md +0 -0
  125. /package/{retro → skills/retro}/SKILL.md.tmpl +0 -0
  126. /package/{review → skills/review}/SKILL.md +0 -0
  127. /package/{review → skills/review}/SKILL.md.tmpl +0 -0
  128. /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md +0 -0
  129. /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md.tmpl +0 -0
  130. /package/{setup-deploy → skills/setup-deploy}/SKILL.md +0 -0
  131. /package/{setup-deploy → skills/setup-deploy}/SKILL.md.tmpl +0 -0
  132. /package/{ship → skills/ship}/SKILL.md +0 -0
  133. /package/{ship → skills/ship}/SKILL.md.tmpl +0 -0
  134. /package/{unfreeze → skills/unfreeze}/SKILL.md +0 -0
  135. /package/{unfreeze → skills/unfreeze}/SKILL.md.tmpl +0 -0
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Tests for bin/gstack-update-check bash script.
3
+ *
4
+ * Uses Bun.spawnSync to invoke the script with temp dirs and
5
+ * GSTACK_DIR / GSTACK_STATE_DIR / GSTACK_REMOTE_URL env overrides
6
+ * for full isolation.
7
+ */
8
+
9
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
10
+ import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync, mkdirSync, symlinkSync, utimesSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { tmpdir } from 'os';
13
+
14
+ const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-update-check');
15
+
16
+ let gstackDir: string;
17
+ let stateDir: string;
18
+
19
+ function run(extraEnv: Record<string, string> = {}, args: string[] = []) {
20
+ const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
21
+ env: {
22
+ ...process.env,
23
+ GSTACK_DIR: gstackDir,
24
+ GSTACK_STATE_DIR: stateDir,
25
+ GSTACK_REMOTE_URL: `file://${join(gstackDir, 'REMOTE_VERSION')}`,
26
+ ...extraEnv,
27
+ },
28
+ stdout: 'pipe',
29
+ stderr: 'pipe',
30
+ });
31
+ return {
32
+ exitCode: result.exitCode,
33
+ stdout: result.stdout.toString().trim(),
34
+ stderr: result.stderr.toString().trim(),
35
+ };
36
+ }
37
+
38
+ beforeEach(() => {
39
+ gstackDir = mkdtempSync(join(tmpdir(), 'gstack-upd-test-'));
40
+ stateDir = mkdtempSync(join(tmpdir(), 'gstack-state-test-'));
41
+ // Link real gstack-config so update_check config check works
42
+ const binDir = join(gstackDir, 'bin');
43
+ mkdirSync(binDir);
44
+ symlinkSync(join(import.meta.dir, '..', '..', 'bin', 'gstack-config'), join(binDir, 'gstack-config'));
45
+ });
46
+
47
+ afterEach(() => {
48
+ rmSync(gstackDir, { recursive: true, force: true });
49
+ rmSync(stateDir, { recursive: true, force: true });
50
+ });
51
+
52
+ function writeSnooze(version: string, level: number, epochSeconds: number) {
53
+ writeFileSync(join(stateDir, 'update-snoozed'), `${version} ${level} ${epochSeconds}`);
54
+ }
55
+
56
+ function writeConfig(content: string) {
57
+ writeFileSync(join(stateDir, 'config.yaml'), content);
58
+ }
59
+
60
+ function nowEpoch(): number {
61
+ return Math.floor(Date.now() / 1000);
62
+ }
63
+
64
+ describe('gstack-update-check', () => {
65
+ // ─── Path A: No VERSION file ────────────────────────────────
66
+ test('exits 0 with no output when VERSION file is missing', () => {
67
+ const { exitCode, stdout } = run();
68
+ expect(exitCode).toBe(0);
69
+ expect(stdout).toBe('');
70
+ });
71
+
72
+ // ─── Path B: Empty VERSION file ─────────────────────────────
73
+ test('exits 0 with no output when VERSION file is empty', () => {
74
+ writeFileSync(join(gstackDir, 'VERSION'), '');
75
+ const { exitCode, stdout } = run();
76
+ expect(exitCode).toBe(0);
77
+ expect(stdout).toBe('');
78
+ });
79
+
80
+ // ─── Path C: Just-upgraded marker ───────────────────────────
81
+ test('outputs JUST_UPGRADED and deletes marker', () => {
82
+ writeFileSync(join(gstackDir, 'VERSION'), '0.4.0\n');
83
+ writeFileSync(join(stateDir, 'just-upgraded-from'), '0.3.3\n');
84
+
85
+ const { exitCode, stdout } = run();
86
+ expect(exitCode).toBe(0);
87
+ expect(stdout).toBe('JUST_UPGRADED 0.3.3 0.4.0');
88
+ // Marker should be deleted
89
+ expect(existsSync(join(stateDir, 'just-upgraded-from'))).toBe(false);
90
+ // Cache should be written
91
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
92
+ expect(cache).toContain('UP_TO_DATE');
93
+ });
94
+
95
+ // ─── Path C2: Just-upgraded marker + newer remote ──────────
96
+ test('just-upgraded marker does not mask newer remote version', () => {
97
+ writeFileSync(join(gstackDir, 'VERSION'), '0.4.0\n');
98
+ writeFileSync(join(stateDir, 'just-upgraded-from'), '0.3.3\n');
99
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.5.0\n');
100
+
101
+ const { exitCode, stdout } = run();
102
+ expect(exitCode).toBe(0);
103
+ // Should output both the just-upgraded notice AND the new upgrade
104
+ expect(stdout).toContain('JUST_UPGRADED 0.3.3 0.4.0');
105
+ expect(stdout).toContain('UPGRADE_AVAILABLE 0.4.0 0.5.0');
106
+ // Cache should reflect the upgrade available, not UP_TO_DATE
107
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
108
+ expect(cache).toContain('UPGRADE_AVAILABLE 0.4.0 0.5.0');
109
+ });
110
+
111
+ // ─── Path C3: Just-upgraded marker + remote matches local ──
112
+ test('just-upgraded with no further updates writes UP_TO_DATE cache', () => {
113
+ writeFileSync(join(gstackDir, 'VERSION'), '0.4.0\n');
114
+ writeFileSync(join(stateDir, 'just-upgraded-from'), '0.3.3\n');
115
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
116
+
117
+ const { exitCode, stdout } = run();
118
+ expect(exitCode).toBe(0);
119
+ expect(stdout).toBe('JUST_UPGRADED 0.3.3 0.4.0');
120
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
121
+ expect(cache).toContain('UP_TO_DATE');
122
+ });
123
+
124
+ // ─── Path D1: Fresh cache, UP_TO_DATE ───────────────────────
125
+ test('exits silently when cache says UP_TO_DATE and is fresh', () => {
126
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
127
+ writeFileSync(join(stateDir, 'last-update-check'), 'UP_TO_DATE 0.3.3');
128
+
129
+ const { exitCode, stdout } = run();
130
+ expect(exitCode).toBe(0);
131
+ expect(stdout).toBe('');
132
+ });
133
+
134
+ // ─── Path D1b: Fresh UP_TO_DATE cache, but local version changed ──
135
+ test('re-checks when UP_TO_DATE cache version does not match local', () => {
136
+ writeFileSync(join(gstackDir, 'VERSION'), '0.4.0\n');
137
+ // Cache says UP_TO_DATE for 0.3.3, but local is now 0.4.0
138
+ writeFileSync(join(stateDir, 'last-update-check'), 'UP_TO_DATE 0.3.3');
139
+ // Remote says 0.5.0 — should detect upgrade
140
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.5.0\n');
141
+
142
+ const { exitCode, stdout } = run();
143
+ expect(exitCode).toBe(0);
144
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.4.0 0.5.0');
145
+ });
146
+
147
+ // ─── Path D2: Fresh cache, UPGRADE_AVAILABLE ────────────────
148
+ test('echoes cached UPGRADE_AVAILABLE when cache is fresh', () => {
149
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
150
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
151
+
152
+ const { exitCode, stdout } = run();
153
+ expect(exitCode).toBe(0);
154
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
155
+ });
156
+
157
+ // ─── Path D3: Fresh cache, but local version changed ────────
158
+ test('re-checks when local version does not match cached old version', () => {
159
+ writeFileSync(join(gstackDir, 'VERSION'), '0.4.0\n');
160
+ // Cache says 0.3.3 → 0.4.0 but we're already on 0.4.0
161
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
162
+ // Remote also says 0.4.0 — should be up to date
163
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
164
+
165
+ const { exitCode, stdout } = run();
166
+ expect(exitCode).toBe(0);
167
+ expect(stdout).toBe(''); // Up to date after re-check
168
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
169
+ expect(cache).toContain('UP_TO_DATE');
170
+ });
171
+
172
+ // ─── Path E: Versions match (remote fetch) ─────────────────
173
+ test('writes UP_TO_DATE cache when versions match', () => {
174
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
175
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.3.3\n');
176
+
177
+ const { exitCode, stdout } = run();
178
+ expect(exitCode).toBe(0);
179
+ expect(stdout).toBe('');
180
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
181
+ expect(cache).toContain('UP_TO_DATE');
182
+ });
183
+
184
+ // ─── Path F: Versions differ (remote fetch) ─────────────────
185
+ test('outputs UPGRADE_AVAILABLE when versions differ', () => {
186
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
187
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
188
+
189
+ const { exitCode, stdout } = run();
190
+ expect(exitCode).toBe(0);
191
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
192
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
193
+ expect(cache).toContain('UPGRADE_AVAILABLE 0.3.3 0.4.0');
194
+ });
195
+
196
+ // ─── Path G: Invalid remote response ────────────────────────
197
+ test('treats invalid remote response as up to date', () => {
198
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
199
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '<html>404 Not Found</html>\n');
200
+
201
+ const { exitCode, stdout } = run();
202
+ expect(exitCode).toBe(0);
203
+ expect(stdout).toBe('');
204
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
205
+ expect(cache).toContain('UP_TO_DATE');
206
+ });
207
+
208
+ // ─── Path H: Curl fails (bad URL) ──────────────────────────
209
+ test('exits silently when remote URL is unreachable', () => {
210
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
211
+
212
+ const { exitCode, stdout } = run({
213
+ GSTACK_REMOTE_URL: 'file:///nonexistent/path/VERSION',
214
+ });
215
+ expect(exitCode).toBe(0);
216
+ expect(stdout).toBe('');
217
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
218
+ expect(cache).toContain('UP_TO_DATE');
219
+ });
220
+
221
+ // ─── Path I: Corrupt cache file ─────────────────────────────
222
+ test('falls through to remote fetch when cache is corrupt', () => {
223
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
224
+ writeFileSync(join(stateDir, 'last-update-check'), 'garbage data here');
225
+ // Remote says same version — should end up UP_TO_DATE
226
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.3.3\n');
227
+
228
+ const { exitCode, stdout } = run();
229
+ expect(exitCode).toBe(0);
230
+ expect(stdout).toBe('');
231
+ // Cache should be overwritten with valid content
232
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
233
+ expect(cache).toContain('UP_TO_DATE');
234
+ });
235
+
236
+ // ─── State dir creation ─────────────────────────────────────
237
+ test('creates state dir if it does not exist', () => {
238
+ const newStateDir = join(stateDir, 'nested', 'dir');
239
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
240
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.3.3\n');
241
+
242
+ const { exitCode } = run({ GSTACK_STATE_DIR: newStateDir });
243
+ expect(exitCode).toBe(0);
244
+ expect(existsSync(join(newStateDir, 'last-update-check'))).toBe(true);
245
+ });
246
+
247
+ // ─── E2E regression: always exit 0 ───────────────────────────
248
+ // Agents call this on every skill invocation. Exit code 1 breaks
249
+ // the preamble and confuses the agent. This test guards against
250
+ // regressions like the "exits 1 when up to date" bug.
251
+ test('exits 0 with real project VERSION and unreachable remote', () => {
252
+ // Simulate agent context: real VERSION file, network unavailable
253
+ const projectRoot = join(import.meta.dir, '..', '..');
254
+ const versionFile = join(projectRoot, 'VERSION');
255
+ if (!existsSync(versionFile)) return; // skip if no VERSION
256
+ const version = readFileSync(versionFile, 'utf-8').trim();
257
+
258
+ // Copy VERSION into test dir
259
+ writeFileSync(join(gstackDir, 'VERSION'), version + '\n');
260
+
261
+ // Remote is unreachable (simulates offline / CI / sandboxed agent)
262
+ const { exitCode, stdout } = run({
263
+ GSTACK_REMOTE_URL: 'file:///nonexistent/path/VERSION',
264
+ });
265
+ expect(exitCode).toBe(0);
266
+ // Should write UP_TO_DATE cache (not crash)
267
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
268
+ expect(cache).toContain('UP_TO_DATE');
269
+ });
270
+
271
+ test('exits 0 when up to date (not exit 1)', () => {
272
+ // Regression test: script previously exited 1 when versions matched.
273
+ // This broke every skill preamble that called it without || true.
274
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
275
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.3.3\n');
276
+
277
+ // First call: fetches remote, writes cache
278
+ const first = run();
279
+ expect(first.exitCode).toBe(0);
280
+ expect(first.stdout).toBe('');
281
+
282
+ // Second call: reads fresh cache
283
+ const second = run();
284
+ expect(second.exitCode).toBe(0);
285
+ expect(second.stdout).toBe('');
286
+
287
+ // Third call with upgrade available: still exit 0
288
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
289
+ rmSync(join(stateDir, 'last-update-check')); // force re-fetch
290
+ const third = run();
291
+ expect(third.exitCode).toBe(0);
292
+ expect(third.stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
293
+ });
294
+
295
+ // ─── Snooze tests ───────────────────────────────────────────
296
+ test('snoozed level 1 within 24h → silent (cached path)', () => {
297
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
298
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
299
+ writeSnooze('0.4.0', 1, nowEpoch() - 3600); // 1h ago (within 24h)
300
+
301
+ const { exitCode, stdout } = run();
302
+ expect(exitCode).toBe(0);
303
+ expect(stdout).toBe('');
304
+ });
305
+
306
+ test('snoozed level 1 expired (25h ago) → outputs UPGRADE_AVAILABLE', () => {
307
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
308
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
309
+ writeSnooze('0.4.0', 1, nowEpoch() - 90000); // 25h ago
310
+
311
+ const { exitCode, stdout } = run();
312
+ expect(exitCode).toBe(0);
313
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
314
+ });
315
+
316
+ test('snoozed level 2 within 48h → silent', () => {
317
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
318
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
319
+ writeSnooze('0.4.0', 2, nowEpoch() - 86400); // 24h ago (within 48h)
320
+
321
+ const { exitCode, stdout } = run();
322
+ expect(exitCode).toBe(0);
323
+ expect(stdout).toBe('');
324
+ });
325
+
326
+ test('snoozed level 2 expired (49h ago) → outputs', () => {
327
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
328
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
329
+ writeSnooze('0.4.0', 2, nowEpoch() - 176400); // 49h ago
330
+
331
+ const { exitCode, stdout } = run();
332
+ expect(exitCode).toBe(0);
333
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
334
+ });
335
+
336
+ test('snoozed level 3 within 7d → silent', () => {
337
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
338
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
339
+ writeSnooze('0.4.0', 3, nowEpoch() - 518400); // 6d ago (within 7d)
340
+
341
+ const { exitCode, stdout } = run();
342
+ expect(exitCode).toBe(0);
343
+ expect(stdout).toBe('');
344
+ });
345
+
346
+ test('snoozed level 3 expired (8d ago) → outputs', () => {
347
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
348
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
349
+ writeSnooze('0.4.0', 3, nowEpoch() - 691200); // 8d ago
350
+
351
+ const { exitCode, stdout } = run();
352
+ expect(exitCode).toBe(0);
353
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
354
+ });
355
+
356
+ test('snooze ignored when version differs (new version resets snooze)', () => {
357
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
358
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.5.0');
359
+ // Snoozed for 0.4.0, but remote is now 0.5.0
360
+ writeSnooze('0.4.0', 3, nowEpoch() - 60); // very recent
361
+
362
+ const { exitCode, stdout } = run();
363
+ expect(exitCode).toBe(0);
364
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.5.0');
365
+ });
366
+
367
+ test('corrupt snooze file → outputs normally', () => {
368
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
369
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
370
+ writeFileSync(join(stateDir, 'update-snoozed'), 'garbage');
371
+
372
+ const { exitCode, stdout } = run();
373
+ expect(exitCode).toBe(0);
374
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
375
+ });
376
+
377
+ test('non-numeric epoch in snooze file → outputs', () => {
378
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
379
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
380
+ writeFileSync(join(stateDir, 'update-snoozed'), '0.4.0 1 abc');
381
+
382
+ const { exitCode, stdout } = run();
383
+ expect(exitCode).toBe(0);
384
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
385
+ });
386
+
387
+ test('non-numeric level in snooze file → outputs', () => {
388
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
389
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
390
+ writeFileSync(join(stateDir, 'update-snoozed'), `0.4.0 abc ${nowEpoch()}`);
391
+
392
+ const { exitCode, stdout } = run();
393
+ expect(exitCode).toBe(0);
394
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
395
+ });
396
+
397
+ test('snooze respected on remote fetch path (no cache)', () => {
398
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
399
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
400
+ // No cache file — goes to remote fetch path
401
+ writeSnooze('0.4.0', 1, nowEpoch() - 3600); // 1h ago
402
+
403
+ const { exitCode, stdout } = run();
404
+ expect(exitCode).toBe(0);
405
+ expect(stdout).toBe('');
406
+ // Cache should still be written
407
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
408
+ expect(cache).toContain('UPGRADE_AVAILABLE 0.3.3 0.4.0');
409
+ });
410
+
411
+ test('just-upgraded clears snooze file', () => {
412
+ writeFileSync(join(gstackDir, 'VERSION'), '0.4.0\n');
413
+ writeFileSync(join(stateDir, 'just-upgraded-from'), '0.3.3\n');
414
+ writeSnooze('0.4.0', 2, nowEpoch() - 3600);
415
+
416
+ const { exitCode, stdout } = run();
417
+ expect(exitCode).toBe(0);
418
+ expect(stdout).toBe('JUST_UPGRADED 0.3.3 0.4.0');
419
+ expect(existsSync(join(stateDir, 'update-snoozed'))).toBe(false);
420
+ });
421
+
422
+ // ─── Config tests ──────────────────────────────────────────
423
+ test('update_check: false disables all checks', () => {
424
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
425
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
426
+ writeConfig('update_check: false\n');
427
+
428
+ const { exitCode, stdout } = run();
429
+ expect(exitCode).toBe(0);
430
+ expect(stdout).toBe('');
431
+ // No cache should be written
432
+ expect(existsSync(join(stateDir, 'last-update-check'))).toBe(false);
433
+ });
434
+
435
+ test('missing config.yaml does not crash', () => {
436
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
437
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
438
+ // No config file — should behave normally
439
+
440
+ const { exitCode, stdout } = run();
441
+ expect(exitCode).toBe(0);
442
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
443
+ });
444
+
445
+ // ─── --force flag tests ──────────────────────────────────────
446
+
447
+ test('--force busts fresh UP_TO_DATE cache', () => {
448
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
449
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
450
+ writeFileSync(join(stateDir, 'last-update-check'), 'UP_TO_DATE 0.3.3');
451
+
452
+ // Without --force: cache hit, silent
453
+ const cached = run();
454
+ expect(cached.stdout).toBe('');
455
+
456
+ // With --force: cache busted, re-fetches, finds upgrade
457
+ const forced = run({}, ['--force']);
458
+ expect(forced.exitCode).toBe(0);
459
+ expect(forced.stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
460
+ });
461
+
462
+ test('--force busts fresh UPGRADE_AVAILABLE cache', () => {
463
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
464
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.3.3\n');
465
+ writeFileSync(join(stateDir, 'last-update-check'), 'UPGRADE_AVAILABLE 0.3.3 0.4.0');
466
+
467
+ // Without --force: cache hit, outputs stale upgrade
468
+ const cached = run();
469
+ expect(cached.stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
470
+
471
+ // With --force: cache busted, re-fetches, now up to date
472
+ const forced = run({}, ['--force']);
473
+ expect(forced.exitCode).toBe(0);
474
+ expect(forced.stdout).toBe('');
475
+ const cache = readFileSync(join(stateDir, 'last-update-check'), 'utf-8');
476
+ expect(cache).toContain('UP_TO_DATE');
477
+ });
478
+
479
+ test('--force clears snooze so user can upgrade after snoozing', () => {
480
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
481
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
482
+ writeSnooze('0.4.0', 1, nowEpoch() - 60); // snoozed 1 min ago (within 24h)
483
+
484
+ // Without --force: snoozed, silent
485
+ const snoozed = run();
486
+ expect(snoozed.exitCode).toBe(0);
487
+ expect(snoozed.stdout).toBe('');
488
+
489
+ // With --force: snooze cleared, outputs upgrade
490
+ const forced = run({}, ['--force']);
491
+ expect(forced.exitCode).toBe(0);
492
+ expect(forced.stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
493
+ // Snooze file should be deleted
494
+ expect(existsSync(join(stateDir, 'update-snoozed'))).toBe(false);
495
+ });
496
+
497
+ // ─── Split TTL tests ─────────────────────────────────────────
498
+
499
+ test('UP_TO_DATE cache expires after 60 min (not 720)', () => {
500
+ writeFileSync(join(gstackDir, 'VERSION'), '0.3.3\n');
501
+ writeFileSync(join(gstackDir, 'REMOTE_VERSION'), '0.4.0\n');
502
+ writeFileSync(join(stateDir, 'last-update-check'), 'UP_TO_DATE 0.3.3');
503
+
504
+ // Set cache mtime to 90 minutes ago (past 60-min TTL)
505
+ const ninetyMinAgo = new Date(Date.now() - 90 * 60 * 1000);
506
+ const cachePath = join(stateDir, 'last-update-check');
507
+ utimesSync(cachePath, ninetyMinAgo, ninetyMinAgo);
508
+
509
+ // Cache should be stale at 60-min TTL, re-fetches and finds upgrade
510
+ const { exitCode, stdout } = run();
511
+ expect(exitCode).toBe(0);
512
+ expect(stdout).toBe('UPGRADE_AVAILABLE 0.3.3 0.4.0');
513
+ });
514
+ });