@happier-dev/stack 0.1.0-preview.142.1 → 0.1.0-preview.21.1

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.
@@ -1,8 +1,10 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { createHash, generateKeyPairSync, sign } from 'node:crypto';
2
3
  import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
3
4
  import test from 'node:test';
4
5
  import { tmpdir } from 'node:os';
5
6
  import { join } from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
6
8
 
7
9
  import {
8
10
  parseSelfHostInvocation,
@@ -16,6 +18,7 @@ import {
16
18
  renderUpdaterScheduledTaskWrapperPs1,
17
19
  renderUpdaterSystemdUnit,
18
20
  renderUpdaterSystemdTimerUnit,
21
+ buildUpdaterScheduledTaskCreateArgs,
19
22
  renderServerEnvFile,
20
23
  renderServerServiceUnit,
21
24
  renderSelfHostStatusText,
@@ -25,6 +28,49 @@ import {
25
28
  mergeEnvTextWithDefaults,
26
29
  } from './self_host_runtime.mjs';
27
30
 
31
+ function b64(buf) {
32
+ return Buffer.from(buf).toString('base64');
33
+ }
34
+
35
+ function base64UrlToBuffer(value) {
36
+ const s = String(value ?? '')
37
+ .replace(/-/g, '+')
38
+ .replace(/_/g, '/')
39
+ .padEnd(Math.ceil(String(value ?? '').length / 4) * 4, '=');
40
+ return Buffer.from(s, 'base64');
41
+ }
42
+
43
+ function createMinisignKeyPair() {
44
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
45
+ const jwk = publicKey.export({ format: 'jwk' });
46
+ const rawPublicKey = base64UrlToBuffer(jwk.x);
47
+ assert.equal(rawPublicKey.length, 32);
48
+
49
+ const keyId = Buffer.from('0123456789abcdef', 'hex');
50
+ const publicKeyBytes = Buffer.concat([Buffer.from('Ed'), keyId, rawPublicKey]);
51
+ const pubkeyFile = `untrusted comment: minisign public key\n${b64(publicKeyBytes)}\n`;
52
+ return { pubkeyFile, keyId, privateKey };
53
+ }
54
+
55
+ function signMinisignMessage({ message, keyId, privateKey }) {
56
+ const signature = sign(null, message, privateKey);
57
+ const sigLineBytes = Buffer.concat([Buffer.from('Ed'), keyId, signature]);
58
+ const trustedComment = 'trusted comment: test';
59
+ const trustedSuffix = Buffer.from(trustedComment.slice('trusted comment: '.length), 'utf-8');
60
+ const globalSignature = sign(null, Buffer.concat([signature, trustedSuffix]), privateKey);
61
+ return [
62
+ 'untrusted comment: signature from happier stack test',
63
+ b64(sigLineBytes),
64
+ trustedComment,
65
+ b64(globalSignature),
66
+ '',
67
+ ].join('\n');
68
+ }
69
+
70
+ function sha256Hex(bytes) {
71
+ return createHash('sha256').update(bytes).digest('hex');
72
+ }
73
+
28
74
  test('parseSelfHostInvocation accepts optional self-host prefix', () => {
29
75
  const parsed = parseSelfHostInvocation(['self-host', 'install', '--channel=preview']);
30
76
  assert.equal(parsed.subcommand, 'install');
@@ -54,6 +100,81 @@ test('pickReleaseAsset returns matching archive and checksum assets', () => {
54
100
  assert.equal(picked.signatureUrl, 'https://example.test/checksums.txt.minisig');
55
101
  });
56
102
 
103
+ test('self-host release installer reports archive source url', async (t) => {
104
+ if (process.platform === 'win32') {
105
+ t.skip('tar-based bundle test does not run on windows');
106
+ return;
107
+ }
108
+ if (spawnSync('bash', ['-lc', 'command -v tar >/dev/null 2>&1'], { stdio: 'ignore' }).status !== 0) {
109
+ t.skip('tar is required for bundle installation test');
110
+ return;
111
+ }
112
+
113
+ const tmp = await mkdtemp(join(tmpdir(), 'happier-self-host-bundle-test-'));
114
+ t.after(async () => {
115
+ await spawnSync('bash', ['-lc', `rm -rf "${tmp.replaceAll('"', '\\"')}"`], { stdio: 'ignore' });
116
+ });
117
+
118
+ const staging = join(tmp, 'staging');
119
+ const rootName = 'happier-server-v1.2.3-preview.1-linux-x64';
120
+ const rootDir = join(staging, rootName);
121
+ await mkdir(join(rootDir, 'generated'), { recursive: true });
122
+ await writeFile(join(rootDir, 'generated', 'dummy.txt'), 'ok', 'utf-8');
123
+
124
+ const binaryName = 'happier-server';
125
+ const binaryPath = join(rootDir, binaryName);
126
+ await writeFile(binaryPath, '#!/bin/sh\necho ok\n', 'utf-8');
127
+ spawnSync('bash', ['-lc', `chmod +x "${binaryPath.replaceAll('"', '\\"')}"`], { stdio: 'ignore' });
128
+
129
+ const archiveName = `${rootName}.tar.gz`;
130
+ const archivePath = join(tmp, archiveName);
131
+ const tar = spawnSync('tar', ['-czf', archivePath, '-C', staging, rootName], { encoding: 'utf-8' });
132
+ assert.equal(tar.status, 0, tar.stderr || tar.stdout);
133
+
134
+ const archiveBytes = await (await import('node:fs/promises')).readFile(archivePath);
135
+ const archiveSha = sha256Hex(archiveBytes);
136
+ const checksumsText = `${archiveSha} ${archiveName}\n`;
137
+ const { pubkeyFile, keyId, privateKey } = createMinisignKeyPair();
138
+ const sigFile = signMinisignMessage({
139
+ message: Buffer.from(checksumsText, 'utf-8'),
140
+ keyId,
141
+ privateKey,
142
+ });
143
+
144
+ const archiveUrl = `data:application/octet-stream;base64,${archiveBytes.toString('base64')}`;
145
+ const checksumsUrl = `data:text/plain,${encodeURIComponent(checksumsText)}`;
146
+ const sigUrl = `data:text/plain,${encodeURIComponent(sigFile)}`;
147
+
148
+ const bundle = {
149
+ version: '1.2.3-preview.1',
150
+ archive: { name: archiveName, url: archiveUrl },
151
+ checksums: { name: `checksums-happier-server-v1.2.3-preview.1.txt`, url: checksumsUrl },
152
+ checksumsSig: { name: `checksums-happier-server-v1.2.3-preview.1.txt.minisig`, url: sigUrl },
153
+ };
154
+
155
+ const installRoot = join(tmp, 'install');
156
+ const config = {
157
+ platform: process.platform,
158
+ dataDir: join(installRoot, 'data'),
159
+ versionsDir: join(installRoot, 'versions'),
160
+ serverBinaryPath: join(installRoot, 'bin', binaryName),
161
+ serverPreviousBinaryPath: join(installRoot, 'bin', `${binaryName}.previous`),
162
+ };
163
+
164
+ const mod = await import('./self_host_runtime.mjs');
165
+ assert.equal(typeof mod.installSelfHostBinaryFromBundle, 'function');
166
+
167
+ const result = await mod.installSelfHostBinaryFromBundle({
168
+ bundle,
169
+ binaryName,
170
+ config,
171
+ pubkeyFile,
172
+ });
173
+
174
+ assert.equal(result.version, '1.2.3-preview.1');
175
+ assert.equal(result.source, archiveUrl);
176
+ });
177
+
57
178
  test('pickReleaseAsset rejects releases missing minisign signature assets', () => {
58
179
  assert.throws(() => {
59
180
  pickReleaseAsset({
@@ -244,6 +365,7 @@ test('renderUpdaterLaunchdPlistXml runs self-host update without keepalive loops
244
365
 
245
366
  assert.match(plist, /<key>RunAtLoad<\/key>\s*<true\/>/);
246
367
  assert.match(plist, /<key>StartInterval<\/key>\s*<integer>3600<\/integer>/);
368
+ assert.doesNotMatch(plist, /<key>StartCalendarInterval<\/key>/);
247
369
  assert.doesNotMatch(plist, /<key>KeepAlive<\/key>/);
248
370
  assert.match(plist, /<key>PATH<\/key>/);
249
371
  assert.match(plist, /<string>\/Users\/me\/\.happier\/bin\/hstack<\/string>/);
@@ -254,16 +376,44 @@ test('renderUpdaterLaunchdPlistXml runs self-host update without keepalive loops
254
376
  assert.match(plist, /<string>--non-interactive<\/string>/);
255
377
  });
256
378
 
379
+ test('renderUpdaterLaunchdPlistXml supports daily time-of-day schedules', () => {
380
+ const plist = renderUpdaterLaunchdPlistXml({
381
+ updaterLabel: 'happier-server-updater',
382
+ hstackPath: '/Users/me/.happier/bin/hstack',
383
+ channel: 'stable',
384
+ mode: 'user',
385
+ at: '03:15',
386
+ workingDirectory: '/Users/me/.happier/self-host',
387
+ stdoutPath: '/Users/me/.happier/self-host/logs/updater.out.log',
388
+ stderrPath: '/Users/me/.happier/self-host/logs/updater.err.log',
389
+ });
390
+ assert.match(plist, /<key>StartCalendarInterval<\/key>/);
391
+ assert.match(plist, /<key>Hour<\/key>\s*<integer>3<\/integer>/);
392
+ assert.match(plist, /<key>Minute<\/key>\s*<integer>15<\/integer>/);
393
+ assert.doesNotMatch(plist, /<key>StartInterval<\/key>/);
394
+ });
395
+
257
396
  test('renderUpdaterSystemdTimerUnit schedules periodic updater runs', () => {
258
397
  const timer = renderUpdaterSystemdTimerUnit({
259
398
  updaterLabel: 'happier-server-updater',
260
399
  intervalMinutes: 60,
261
400
  });
262
401
  assert.match(timer, /OnUnitActiveSec=60m/);
402
+ assert.doesNotMatch(timer, /OnCalendar=/);
263
403
  assert.match(timer, /Unit=happier-server-updater\.service/);
264
404
  assert.match(timer, /WantedBy=timers\.target/);
265
405
  });
266
406
 
407
+ test('renderUpdaterSystemdTimerUnit supports daily time-of-day schedules', () => {
408
+ const timer = renderUpdaterSystemdTimerUnit({
409
+ updaterLabel: 'happier-server-updater',
410
+ at: '03:15',
411
+ });
412
+ assert.match(timer, /OnCalendar=\*-\*-\*\s+03:15:00/);
413
+ assert.doesNotMatch(timer, /OnUnitActiveSec=/);
414
+ assert.match(timer, /Unit=happier-server-updater\.service/);
415
+ });
416
+
267
417
  test('renderUpdaterScheduledTaskWrapperPs1 runs self-host update without node dependencies', () => {
268
418
  const wrapper = renderUpdaterScheduledTaskWrapperPs1({
269
419
  updaterLabel: 'happier-server-updater',
@@ -281,6 +431,18 @@ test('renderUpdaterScheduledTaskWrapperPs1 runs self-host update without node de
281
431
  );
282
432
  });
283
433
 
434
+ test('buildUpdaterScheduledTaskCreateArgs uses DAILY schedule when at is provided', () => {
435
+ const args = buildUpdaterScheduledTaskCreateArgs({
436
+ backend: 'schtasks-user',
437
+ taskName: 'Happier\\\\happier-server-updater',
438
+ definitionPath: 'C:\\\\Users\\\\me\\\\.happier\\\\self-host\\\\services\\\\happier-server-updater.ps1',
439
+ at: '03:15',
440
+ });
441
+ assert.ok(args.includes('DAILY'));
442
+ assert.ok(args.includes('03:15'));
443
+ assert.equal(args.includes('MINUTE'), false);
444
+ });
445
+
284
446
  test('mergeEnvTextWithDefaults preserves overrides while backfilling new default keys', () => {
285
447
  const defaults = renderServerEnvFile({
286
448
  port: 3005,
@@ -403,40 +565,40 @@ test('buildSelfHostDoctorChecks flags missing ui-web bundle when state expects u
403
565
  test('normalizeSelfHostAutoUpdateState upgrades legacy boolean config to structured config', () => {
404
566
  assert.deepEqual(
405
567
  normalizeSelfHostAutoUpdateState({ autoUpdate: true }, { fallbackIntervalMinutes: 1440 }),
406
- { enabled: true, intervalMinutes: 1440 },
568
+ { enabled: true, intervalMinutes: 1440, at: '' },
407
569
  );
408
570
  assert.deepEqual(
409
571
  normalizeSelfHostAutoUpdateState({ autoUpdate: false }, { fallbackIntervalMinutes: 1440 }),
410
- { enabled: false, intervalMinutes: 1440 },
572
+ { enabled: false, intervalMinutes: 1440, at: '' },
411
573
  );
412
574
  });
413
575
 
414
576
  test('normalizeSelfHostAutoUpdateState preserves explicit interval and bounds invalid values', () => {
415
577
  assert.deepEqual(
416
578
  normalizeSelfHostAutoUpdateState({ autoUpdate: { enabled: true, intervalMinutes: 60 } }, { fallbackIntervalMinutes: 1440 }),
417
- { enabled: true, intervalMinutes: 60 },
579
+ { enabled: true, intervalMinutes: 60, at: '' },
418
580
  );
419
581
  assert.deepEqual(
420
582
  normalizeSelfHostAutoUpdateState({ autoUpdate: { enabled: true, intervalMinutes: 0 } }, { fallbackIntervalMinutes: 1440 }),
421
- { enabled: true, intervalMinutes: 1440 },
583
+ { enabled: true, intervalMinutes: 1440, at: '' },
422
584
  );
423
585
  assert.deepEqual(
424
586
  normalizeSelfHostAutoUpdateState({}, { fallbackIntervalMinutes: 1440 }),
425
- { enabled: false, intervalMinutes: 1440 },
587
+ { enabled: false, intervalMinutes: 1440, at: '' },
426
588
  );
427
589
  });
428
590
 
429
591
  test('decideSelfHostAutoUpdateReconcile maps configured state to an install/uninstall action', () => {
430
592
  assert.deepEqual(
431
593
  decideSelfHostAutoUpdateReconcile({ autoUpdate: true }, { fallbackIntervalMinutes: 1440 }),
432
- { action: 'install', enabled: true, intervalMinutes: 1440 },
594
+ { action: 'install', enabled: true, intervalMinutes: 1440, at: '' },
433
595
  );
434
596
  assert.deepEqual(
435
597
  decideSelfHostAutoUpdateReconcile({ autoUpdate: false }, { fallbackIntervalMinutes: 1440 }),
436
- { action: 'uninstall', enabled: false, intervalMinutes: 1440 },
598
+ { action: 'uninstall', enabled: false, intervalMinutes: 1440, at: '' },
437
599
  );
438
600
  assert.deepEqual(
439
601
  decideSelfHostAutoUpdateReconcile({}, { fallbackIntervalMinutes: 1440 }),
440
- { action: 'uninstall', enabled: false, intervalMinutes: 1440 },
602
+ { action: 'uninstall', enabled: false, intervalMinutes: 1440, at: '' },
441
603
  );
442
604
  });
@@ -6,7 +6,7 @@ const STACK_HELP_USAGE_LINES = [
6
6
  'hstack stack archive <name> [--dry-run] [--date=YYYY-MM-DD] [--json]',
7
7
  'hstack stack duplicate <from> <to> [--duplicate-worktrees] [--deps=none|link|install|link-or-install] [--json]',
8
8
  'hstack stack info <name> [--json]',
9
- 'hstack stack pr <name> --repo=<pr-url|number> [--server-flavor=light|full] [--dev|--start] [--json] [-- ...]',
9
+ 'hstack stack pr <name> --repo=<pr-url|number> [--server-flavor=light|full] [--dev|--start] [--reuse] [--update] [--force] [--background] [--mobile] [--expo-tailscale] [--json] [-- ...]',
10
10
  'hstack stack create-dev-auth-seed [name] [--server=happier-server|happier-server-light] [--login|--no-login] [--skip-default-seed] [--non-interactive] [--json]',
11
11
  'hstack stack daemon <name> start|stop|restart|status [--json]',
12
12
  'hstack stack happier <name> [-- ...]',
package/scripts/stack.mjs CHANGED
@@ -1622,7 +1622,7 @@ async function cmdPrStack({ rootDir, argv }) {
1622
1622
  '[stack] usage:',
1623
1623
  ' hstack stack pr <name> --repo=<pr-url|number> [--dev|--start]',
1624
1624
  ' [--seed-auth] [--copy-auth-from=<stack>] [--link-auth] [--with-infra] [--auth-force]',
1625
- ' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force] [--background]',
1625
+ ' [--remote=upstream] [--deps=none|link|install|link-or-install] [--reuse] [--update] [--force] [--background]',
1626
1626
  ' [--mobile] # also start Expo dev-client Metro for mobile',
1627
1627
  ' [--expo-tailscale] # forward Expo to Tailscale interface for remote access',
1628
1628
  ' [--json] [-- <stack dev/start args...>]',
@@ -1639,6 +1639,7 @@ async function cmdPrStack({ rootDir, argv }) {
1639
1639
  '',
1640
1640
  'notes:',
1641
1641
  ' - This composes existing commands: `hstack stack new`, `hstack stack wt ...`, and `hstack stack auth ...`',
1642
+ ' - `--reuse` reuses an existing PR stack (otherwise `stack pr` fails closed if the stack already exists)',
1642
1643
  ' - For auth seeding, pass `--seed-auth` and optionally `--copy-auth-from=dev-auth` (or main)',
1643
1644
  ' - `--link-auth` symlinks auth files instead of copying (keeps credentials in sync, but reduces isolation)',
1644
1645
  ].join('\n'),
@@ -0,0 +1,38 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { join } from 'node:path';
4
+
5
+ import { getStackRootFromMeta, hstackBinPath, runNodeCapture } from './testkit/auth_testkit.mjs';
6
+
7
+ test('hstack stack pr --help surfaces --reuse (supported flag)', async () => {
8
+ const rootDir = getStackRootFromMeta(import.meta.url);
9
+
10
+ const env = {
11
+ ...process.env,
12
+ // Prevent env.mjs from auto-loading a real machine stack env file (keeps the test hermetic).
13
+ HAPPIER_STACK_STACK: 'test-stack',
14
+ HAPPIER_STACK_ENV_FILE: join(rootDir, 'scripts', 'nonexistent-env'),
15
+ };
16
+
17
+ const res = await runNodeCapture([hstackBinPath(rootDir), 'stack', 'pr', '--help'], { cwd: rootDir, env });
18
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstderr:\n${res.stderr}\nstdout:\n${res.stdout}`);
19
+ assert.match(res.stdout, /--reuse/, `expected stack pr help to include --reuse\nstdout:\n${res.stdout}`);
20
+ });
21
+
22
+ test('hstack stack --help shows --reuse on the stack pr usage line', async () => {
23
+ const rootDir = getStackRootFromMeta(import.meta.url);
24
+
25
+ const env = {
26
+ ...process.env,
27
+ HAPPIER_STACK_STACK: 'test-stack',
28
+ HAPPIER_STACK_ENV_FILE: join(rootDir, 'scripts', 'nonexistent-env'),
29
+ };
30
+
31
+ const res = await runNodeCapture([hstackBinPath(rootDir), 'stack', '--help'], { cwd: rootDir, env });
32
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstderr:\n${res.stderr}\nstdout:\n${res.stdout}`);
33
+ assert.match(
34
+ res.stdout,
35
+ /hstack stack pr[^\n]*--reuse\b/,
36
+ `expected stack root help to include --reuse on the stack pr usage line\nstdout:\n${res.stdout}`
37
+ );
38
+ });
package/scripts/stop.mjs CHANGED
@@ -57,7 +57,7 @@ async function listAllStackNames() {
57
57
  async function main() {
58
58
  const rootDir = getRootDir(import.meta.url);
59
59
  const argv = process.argv.slice(2);
60
- const { flags, kv } = parseArgs(argv);
60
+ const { flags, kv: argsKv } = parseArgs(argv);
61
61
  const json = wantsJson(argv, { flags });
62
62
 
63
63
  if (wantsHelp(argv, { flags })) {
@@ -65,7 +65,7 @@ async function main() {
65
65
  return;
66
66
  }
67
67
 
68
- const exceptStacks = new Set(parseCsv(kv.get('--except-stacks')));
68
+ const exceptStacks = new Set(parseCsv(argsKv.get('--except-stacks')));
69
69
  const yes = flags.has('--yes');
70
70
  const aggressive = flags.has('--aggressive');
71
71
  const sweepOwned = flags.has('--sweep-owned');
@@ -187,4 +187,3 @@ main().catch((err) => {
187
187
  console.error('[stop] failed:', err);
188
188
  process.exit(1);
189
189
  });
190
-
@@ -19,15 +19,76 @@ function appendCauseText(baseMessage, cause) {
19
19
  return `${msg}\n\n[auth] Cause: ${c}`;
20
20
  }
21
21
 
22
+ async function readTextWithTimeout(path, { timeoutMs = 1200 } = {}) {
23
+ try {
24
+ const controller = new AbortController();
25
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
26
+ const res = await fetch(path, { signal: controller.signal });
27
+ clearTimeout(timer);
28
+ return { ok: res.ok, status: res.status };
29
+ } catch (error) {
30
+ return { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
31
+ }
32
+ }
33
+
34
+ function formatPidStatus(label, pidRaw) {
35
+ const pid = Number(pidRaw);
36
+ if (!Number.isFinite(pid) || pid <= 1) return `[auth] ${label}: unavailable`;
37
+ return `[auth] ${label}: ${pid} (${isPidAlive(pid) ? 'alive' : 'stale/dead'})`;
38
+ }
39
+
40
+ function formatLogPath(label, logPath) {
41
+ const p = String(logPath ?? '').trim();
42
+ return p ? `[auth] ${label}: ${p}` : `[auth] ${label}: unavailable`;
43
+ }
44
+
45
+ async function appendRuntimeHealthDiagnostics(message, stackName) {
46
+ const name = String(stackName ?? '').trim() || 'main';
47
+ const statePath = getStackRuntimeStatePath(name);
48
+
49
+ const state = await readStackRuntimeStateFile(statePath).catch(() => null);
50
+ if (!state || typeof state !== 'object') {
51
+ return `${message}\n\n[auth] Stack runtime state unavailable: ${statePath}`;
52
+ }
53
+
54
+ const serverPort = Number(state?.ports?.server);
55
+ let serverHealth = 'not configured';
56
+ if (Number.isFinite(serverPort) && serverPort > 0) {
57
+ const probe = await readTextWithTimeout(`http://127.0.0.1:${serverPort}/health`, { timeoutMs: 1200 });
58
+ if (probe.ok) {
59
+ serverHealth = `HTTP ${probe.status}`;
60
+ } else if (probe.error) {
61
+ serverHealth = probe.error;
62
+ } else {
63
+ serverHealth = `HTTP ${probe.status}`;
64
+ }
65
+ }
66
+
67
+ const runtimeSummary = [
68
+ `[auth] Stack runtime path: ${statePath}`,
69
+ formatPidStatus('ownerPid', state?.ownerPid),
70
+ formatPidStatus('serverPid', state?.processes?.serverPid),
71
+ formatPidStatus('expoPid', state?.processes?.expoPid),
72
+ formatPidStatus('expoTailscaleForwarderPid', state?.processes?.expoTailscaleForwarderPid),
73
+ `[auth] server port: ${Number.isFinite(serverPort) && serverPort > 0 ? serverPort : 'unconfigured'}`,
74
+ `[auth] server health: ${serverHealth}`,
75
+ formatLogPath('runner log', state?.logs?.runner),
76
+ formatLogPath('cli log', state?.logs?.cli),
77
+ ].join('\n');
78
+
79
+ return `${String(message ?? '').trim()}\n\n${runtimeSummary}`;
80
+ }
81
+
22
82
  async function appendRunnerLogTailDiagnostics({ message, stackName, lines = 140 }) {
23
83
  const base = String(message ?? '').trim();
24
84
  const logPath = await resolveRunnerLogPathFromRuntime({ stackName, waitMs: 1000, pollMs: 100 }).catch(() => '');
25
- if (!logPath) return base;
85
+ const withState = await appendRuntimeHealthDiagnostics(base, stackName).catch(() => base);
86
+ if (!logPath) return withState;
26
87
  const tail = await readLastLines(logPath, lines).catch(() => null);
27
88
  if (!tail || !String(tail).trim()) {
28
- return `${base}\n\n[auth] Stack runner log: ${logPath}`;
89
+ return `${withState}\n\n[auth] Stack runner log: ${logPath}`;
29
90
  }
30
- return `${base}\n\n[auth] Stack runner log: ${logPath}\n\n[auth] Last runner log lines:\n${String(tail).trimEnd()}`;
91
+ return `${withState}\n\n[auth] Stack runner log: ${logPath}\n\n[auth] Last runner log lines:\n${String(tail).trimEnd()}`;
31
92
  }
32
93
 
33
94
  async function tryStartStackUiInBackgroundForAuth({ rootDir, stackName, env = process.env } = {}) {
@@ -249,8 +249,11 @@ export function gethstackRegistry() {
249
249
  kind: 'node',
250
250
  scriptRelPath: 'scripts/remote_cmd.mjs',
251
251
  rootUsage:
252
- 'hstack remote daemon setup --ssh <user@host> [--preview|--stable] [--channel <stable|preview>] [--service <user|none>] [--server-url=<url>] [--webapp-url=<url>] [--public-server-url=<url>] [--json]',
253
- description: 'Remote setup helpers (SSH pairing + daemon setup)',
252
+ [
253
+ 'hstack remote daemon setup --ssh <user@host> [--preview|--stable] [--channel <stable|preview>] [--service <user|none>] [--server-url=<url>] [--webapp-url=<url>] [--public-server-url=<url>] [--json]',
254
+ 'hstack remote server setup --ssh <user@host> [--preview|--stable] [--channel <stable|preview>] [--mode <user|system>] [--env KEY=VALUE]... [--json]',
255
+ ],
256
+ description: 'Remote setup helpers (SSH daemon/server setup)',
254
257
  },
255
258
  {
256
259
  name: 'providers',
@@ -0,0 +1,68 @@
1
+ import { join } from 'node:path';
2
+
3
+ import { parseArgs } from '../cli/args.mjs';
4
+ import { defaultDevClientIdentity } from './identifiers.mjs';
5
+
6
+ function normalizePortArg(raw) {
7
+ const s = String(raw ?? '').trim();
8
+ if (!s) return '';
9
+ const n = Number(s);
10
+ if (!Number.isFinite(n) || n <= 0) return '';
11
+ return String(Math.floor(n));
12
+ }
13
+
14
+ export function buildMobileDevClientInstallInvocation({
15
+ rootDir,
16
+ argv,
17
+ baseEnv = process.env,
18
+ } = {}) {
19
+ const r = String(rootDir ?? '').trim();
20
+ if (!r) {
21
+ throw new Error('[mobile-dev-client] missing rootDir');
22
+ }
23
+
24
+ const a = Array.isArray(argv) ? argv : [];
25
+ const { flags, kv } = parseArgs(a);
26
+
27
+ const device = (kv.get('--device') ?? '').toString();
28
+ const clean = flags.has('--clean');
29
+ const configuration = (kv.get('--configuration') ?? 'Debug').toString() || 'Debug';
30
+ const port = normalizePortArg(kv.get('--port'));
31
+
32
+ const user = (baseEnv.USER ?? baseEnv.USERNAME ?? 'user').toString();
33
+ const identity = defaultDevClientIdentity({ user });
34
+
35
+ const mobileScript = join(r, 'scripts', 'mobile.mjs');
36
+
37
+ const nodeArgs = [
38
+ mobileScript,
39
+ '--app-env=development',
40
+ `--ios-app-name=${identity.iosAppName}`,
41
+ `--ios-bundle-id=${identity.iosBundleId}`,
42
+ `--scheme=${identity.scheme}`,
43
+ ...(port ? [`--port=${port}`] : []),
44
+ '--prebuild',
45
+ ...(clean ? ['--clean'] : []),
46
+ '--run-ios',
47
+ `--configuration=${configuration}`,
48
+ '--no-metro',
49
+ ...(device ? [`--device=${device}`] : []),
50
+ ];
51
+
52
+ const env = {
53
+ ...baseEnv,
54
+ EXPO_APP_SCHEME: identity.scheme,
55
+ EXPO_PUBLIC_HAPPY_STORAGE_SCOPE: baseEnv.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE ?? '',
56
+ };
57
+
58
+ return {
59
+ nodeArgs,
60
+ env,
61
+ identity,
62
+ device,
63
+ clean,
64
+ configuration,
65
+ port,
66
+ };
67
+ }
68
+
@@ -0,0 +1,27 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { buildMobileDevClientInstallInvocation } from './dev_client_install_invocation.mjs';
5
+
6
+ test('buildMobileDevClientInstallInvocation forwards --port to mobile.mjs args', async () => {
7
+ const invocation = buildMobileDevClientInstallInvocation({
8
+ rootDir: '/repo/apps/stack',
9
+ argv: ['--install', '--port=14362'],
10
+ baseEnv: { USER: 'leeroy' },
11
+ });
12
+
13
+ assert.ok(Array.isArray(invocation.nodeArgs), 'expected nodeArgs array');
14
+ assert.ok(invocation.nodeArgs.includes('--port=14362'), 'expected --port to be forwarded to mobile.mjs');
15
+ });
16
+
17
+ test('buildMobileDevClientInstallInvocation omits --port when not provided', async () => {
18
+ const invocation = buildMobileDevClientInstallInvocation({
19
+ rootDir: '/repo/apps/stack',
20
+ argv: ['--install'],
21
+ baseEnv: { USER: 'leeroy' },
22
+ });
23
+
24
+ assert.ok(Array.isArray(invocation.nodeArgs), 'expected nodeArgs array');
25
+ assert.ok(!invocation.nodeArgs.some((a) => String(a).startsWith('--port=')), 'expected no --port arg by default');
26
+ });
27
+