@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.
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts +4 -0
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts.map +1 -1
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.js +21 -1
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.js.map +1 -1
- package/package.json +1 -1
- package/scripts/auth_login_guided_server_no_expo.test.mjs +2 -0
- package/scripts/bundleWorkspaceDeps.test.mjs +26 -1
- package/scripts/mobile.mjs +4 -7
- package/scripts/mobile_dev_client.mjs +7 -32
- package/scripts/mobile_dev_client_help_smoke.test.mjs +24 -0
- package/scripts/mobile_run_ios_passes_port.integration.test.mjs +8 -6
- package/scripts/mobile_run_ios_uses_long_port_flag.test.mjs +106 -0
- package/scripts/remote_cmd.mjs +112 -0
- package/scripts/self_host_runtime.mjs +505 -94
- package/scripts/self_host_runtime.test.mjs +170 -8
- package/scripts/stack/help_text.mjs +1 -1
- package/scripts/stack.mjs +2 -1
- package/scripts/stack_pr_help_cmd.test.mjs +38 -0
- package/scripts/stop.mjs +2 -3
- package/scripts/utils/auth/orchestrated_stack_auth_flow.mjs +64 -3
- package/scripts/utils/cli/cli_registry.mjs +5 -2
- package/scripts/utils/mobile/dev_client_install_invocation.mjs +68 -0
- package/scripts/utils/mobile/dev_client_install_invocation.test.mjs +27 -0
|
@@ -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(
|
|
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
|
-
|
|
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 `${
|
|
89
|
+
return `${withState}\n\n[auth] Stack runner log: ${logPath}`;
|
|
29
90
|
}
|
|
30
|
-
return `${
|
|
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
|
-
|
|
253
|
-
|
|
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
|
+
|