@happier-dev/stack 0.1.0-preview.100.1 → 0.1.0-preview.134.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.
Files changed (109) hide show
  1. package/docs/server-flavors.md +6 -6
  2. package/node_modules/@happier-dev/cli-common/dist/index.d.ts +2 -0
  3. package/node_modules/@happier-dev/cli-common/dist/index.d.ts.map +1 -1
  4. package/node_modules/@happier-dev/cli-common/dist/index.js +2 -0
  5. package/node_modules/@happier-dev/cli-common/dist/index.js.map +1 -1
  6. package/node_modules/@happier-dev/cli-common/dist/providers/index.d.ts +51 -0
  7. package/node_modules/@happier-dev/cli-common/dist/providers/index.d.ts.map +1 -0
  8. package/node_modules/@happier-dev/cli-common/dist/providers/index.js +129 -0
  9. package/node_modules/@happier-dev/cli-common/dist/providers/index.js.map +1 -0
  10. package/node_modules/@happier-dev/cli-common/dist/service/index.d.ts +5 -0
  11. package/node_modules/@happier-dev/cli-common/dist/service/index.d.ts.map +1 -0
  12. package/node_modules/@happier-dev/cli-common/dist/service/index.js +5 -0
  13. package/node_modules/@happier-dev/cli-common/dist/service/index.js.map +1 -0
  14. package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts +15 -0
  15. package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts.map +1 -0
  16. package/node_modules/@happier-dev/cli-common/dist/service/launchd.js +97 -0
  17. package/node_modules/@happier-dev/cli-common/dist/service/launchd.js.map +1 -0
  18. package/node_modules/@happier-dev/cli-common/dist/service/manager.d.ts +55 -0
  19. package/node_modules/@happier-dev/cli-common/dist/service/manager.d.ts.map +1 -0
  20. package/node_modules/@happier-dev/cli-common/dist/service/manager.js +302 -0
  21. package/node_modules/@happier-dev/cli-common/dist/service/manager.js.map +1 -0
  22. package/node_modules/@happier-dev/cli-common/dist/service/systemd.d.ts +12 -0
  23. package/node_modules/@happier-dev/cli-common/dist/service/systemd.d.ts.map +1 -0
  24. package/node_modules/@happier-dev/cli-common/dist/service/systemd.js +75 -0
  25. package/node_modules/@happier-dev/cli-common/dist/service/systemd.js.map +1 -0
  26. package/node_modules/@happier-dev/cli-common/dist/service/windows.d.ts +8 -0
  27. package/node_modules/@happier-dev/cli-common/dist/service/windows.d.ts.map +1 -0
  28. package/node_modules/@happier-dev/cli-common/dist/service/windows.js +29 -0
  29. package/node_modules/@happier-dev/cli-common/dist/service/windows.js.map +1 -0
  30. package/node_modules/@happier-dev/cli-common/package.json +11 -0
  31. package/node_modules/@happier-dev/release-runtime/dist/assets.d.ts +22 -0
  32. package/node_modules/@happier-dev/release-runtime/dist/assets.d.ts.map +1 -0
  33. package/node_modules/@happier-dev/release-runtime/dist/assets.js +44 -0
  34. package/node_modules/@happier-dev/release-runtime/dist/assets.js.map +1 -0
  35. package/node_modules/@happier-dev/release-runtime/dist/checksums.d.ts +5 -0
  36. package/node_modules/@happier-dev/release-runtime/dist/checksums.d.ts.map +1 -0
  37. package/node_modules/@happier-dev/release-runtime/dist/checksums.js +21 -0
  38. package/node_modules/@happier-dev/release-runtime/dist/checksums.js.map +1 -0
  39. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.d.ts +14 -0
  40. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.d.ts.map +1 -0
  41. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.js +39 -0
  42. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.js.map +1 -0
  43. package/node_modules/@happier-dev/release-runtime/dist/github.d.ts +20 -0
  44. package/node_modules/@happier-dev/release-runtime/dist/github.d.ts.map +1 -0
  45. package/node_modules/@happier-dev/release-runtime/dist/github.js +60 -0
  46. package/node_modules/@happier-dev/release-runtime/dist/github.js.map +1 -0
  47. package/node_modules/@happier-dev/release-runtime/dist/index.d.ts +7 -0
  48. package/node_modules/@happier-dev/release-runtime/dist/index.d.ts.map +1 -0
  49. package/node_modules/@happier-dev/release-runtime/dist/index.js +7 -0
  50. package/node_modules/@happier-dev/release-runtime/dist/index.js.map +1 -0
  51. package/node_modules/@happier-dev/release-runtime/dist/minisign.d.ts +7 -0
  52. package/node_modules/@happier-dev/release-runtime/dist/minisign.d.ts.map +1 -0
  53. package/node_modules/@happier-dev/release-runtime/dist/minisign.js +92 -0
  54. package/node_modules/@happier-dev/release-runtime/dist/minisign.js.map +1 -0
  55. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.d.ts +26 -0
  56. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.d.ts.map +1 -0
  57. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.js +53 -0
  58. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.js.map +1 -0
  59. package/node_modules/@happier-dev/release-runtime/package.json +38 -0
  60. package/package.json +4 -2
  61. package/scripts/auth.mjs +3 -2
  62. package/scripts/auth_copy_from_pglite_lock_in_use.integration.test.mjs +1 -0
  63. package/scripts/auth_copy_from_runCapture.integration.test.mjs +8 -1
  64. package/scripts/build.mjs +3 -18
  65. package/scripts/bundleWorkspaceDeps.mjs +5 -1
  66. package/scripts/bundleWorkspaceDeps.test.mjs +16 -0
  67. package/scripts/mobile.mjs +32 -1
  68. package/scripts/mobile_prebuild_happyDir_defined.test.mjs +47 -0
  69. package/scripts/mobile_prebuild_sets_rct_metro_port.test.mjs +81 -0
  70. package/scripts/mobile_run_ios_passes_port.integration.test.mjs +101 -0
  71. package/scripts/providers_cmd.mjs +262 -0
  72. package/scripts/release_binary_smoke.integration.test.mjs +25 -6
  73. package/scripts/remote_cmd.mjs +240 -0
  74. package/scripts/self_host_daemon.real.integration.test.mjs +296 -0
  75. package/scripts/self_host_launchd.real.integration.test.mjs +211 -0
  76. package/scripts/self_host_runtime.mjs +1403 -312
  77. package/scripts/self_host_runtime.test.mjs +361 -1
  78. package/scripts/self_host_schtasks.real.integration.test.mjs +217 -0
  79. package/scripts/self_host_service_e2e_harness.mjs +93 -0
  80. package/scripts/self_host_systemd.real.integration.test.mjs +8 -86
  81. package/scripts/service.mjs +156 -26
  82. package/scripts/stack/command_arguments.mjs +1 -0
  83. package/scripts/stack/help_text.mjs +2 -0
  84. package/scripts/stack_daemon_cmd.integration.test.mjs +37 -0
  85. package/scripts/stack_happy_cmd.integration.test.mjs +36 -0
  86. package/scripts/utils/auth/credentials_paths.mjs +9 -9
  87. package/scripts/utils/auth/credentials_paths.test.mjs +8 -0
  88. package/scripts/utils/auth/stable_scope_id.mjs +1 -1
  89. package/scripts/utils/cli/cli_registry.mjs +18 -0
  90. package/scripts/utils/cli/progress.mjs +8 -1
  91. package/scripts/utils/cli/progress.test.mjs +43 -0
  92. package/scripts/utils/dev/expo_dev.buildEnv.test.mjs +17 -0
  93. package/scripts/utils/dev/expo_dev.mjs +35 -5
  94. package/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs +180 -1
  95. package/scripts/utils/dev/expo_dev_runtime_metadata.test.mjs +126 -0
  96. package/scripts/utils/dev/expo_dev_verbose_logs.test.mjs +9 -2
  97. package/scripts/utils/server/port.mjs +20 -2
  98. package/scripts/utils/service/service_manager.definition.test.mjs +66 -0
  99. package/scripts/utils/service/service_manager.mjs +96 -0
  100. package/scripts/utils/service/service_manager.plan.test.mjs +37 -0
  101. package/scripts/utils/service/service_manager.test.mjs +20 -0
  102. package/scripts/utils/service/systemd_service_unit.mjs +1 -0
  103. package/scripts/utils/service/systemd_service_unit.test.mjs +42 -0
  104. package/scripts/utils/service/windows_schtasks_wrapper.mjs +1 -0
  105. package/scripts/utils/service/windows_schtasks_wrapper.test.mjs +25 -0
  106. package/scripts/utils/ui/ui_export_env.mjs +29 -0
  107. package/scripts/utils/ui/ui_export_env.test.mjs +25 -0
  108. package/scripts/worktrees.mjs +3 -0
  109. package/scripts/worktrees_status_default_target.test.mjs +56 -0
@@ -0,0 +1,93 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtemp, readdir } from 'node:fs/promises';
4
+ import { createServer } from 'node:net';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ const SELF_HOST_PORT_MIN = 20_000;
9
+ const SELF_HOST_PORT_MAX = 29_999;
10
+ const SELF_HOST_PORT_ATTEMPTS = 40;
11
+
12
+ export function commandExists(cmd) {
13
+ const name = String(cmd ?? '').trim();
14
+ if (!name) return false;
15
+ if (process.platform === 'win32') {
16
+ return spawnSync('where', [name], { stdio: 'ignore' }).status === 0;
17
+ }
18
+ return spawnSync('sh', ['-lc', `command -v ${name} >/dev/null 2>&1`], { stdio: 'ignore' }).status === 0;
19
+ }
20
+
21
+ export function run(cmd, args, { label, cwd, env, timeoutMs = 0, allowFail = false, stdio = 'pipe' } = {}) {
22
+ const prefix = label ? `[${label}] ` : '';
23
+ const result = spawnSync(cmd, args, {
24
+ cwd,
25
+ env: env ?? process.env,
26
+ encoding: 'utf-8',
27
+ stdio,
28
+ timeout: timeoutMs || undefined,
29
+ });
30
+ const timedOut = result.error && result.error.code === 'ETIMEDOUT';
31
+ if (!allowFail && (timedOut || (result.status ?? 1) !== 0)) {
32
+ const stderr = String(result.stderr ?? '').trim();
33
+ const stdout = String(result.stdout ?? '').trim();
34
+ const reason = timedOut ? 'timed out' : `exited with status ${result.status}`;
35
+ throw new Error(`${prefix}${cmd} ${args.join(' ')} ${reason}\nstdout:\n${stdout}\nstderr:\n${stderr}`);
36
+ }
37
+ return result;
38
+ }
39
+
40
+ export async function extractBinaryFromArtifact({ artifactPath, binaryName, label } = {}) {
41
+ const path = String(artifactPath ?? '').trim();
42
+ const name = String(binaryName ?? '').trim();
43
+ assert.ok(path, 'artifactPath is required');
44
+ assert.ok(name, 'binaryName is required');
45
+ const extractDir = await mkdtemp(join(tmpdir(), 'happier-self-host-artifact-'));
46
+ run('tar', ['-xzf', path, '-C', extractDir], { label, timeoutMs: 60_000 });
47
+ const roots = await readdir(extractDir);
48
+ assert.ok(roots.length > 0, `expected extracted root directory for ${path}`);
49
+ return {
50
+ extractDir,
51
+ binaryPath: join(extractDir, roots[0], name),
52
+ };
53
+ }
54
+
55
+ export async function waitForHealth(url, timeoutMs = 60_000) {
56
+ const started = Date.now();
57
+ while (Date.now() - started < timeoutMs) {
58
+ try {
59
+ const response = await fetch(String(url), {
60
+ headers: { accept: 'application/json' },
61
+ });
62
+ if (response.ok) {
63
+ const payload = await response.json().catch(() => ({}));
64
+ if (payload?.ok === true) return true;
65
+ }
66
+ } catch {
67
+ // keep polling until timeout
68
+ }
69
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, 1500));
70
+ }
71
+ return false;
72
+ }
73
+
74
+ async function canBindLocalhostPort(port) {
75
+ return await new Promise((resolvePort) => {
76
+ const server = createServer();
77
+ server.unref();
78
+ server.once('error', () => resolvePort(false));
79
+ server.listen(port, '127.0.0.1', () => {
80
+ server.close((error) => resolvePort(!error));
81
+ });
82
+ });
83
+ }
84
+
85
+ export async function reserveLocalhostPort() {
86
+ for (let attempt = 0; attempt < SELF_HOST_PORT_ATTEMPTS; attempt += 1) {
87
+ const candidate =
88
+ SELF_HOST_PORT_MIN + Math.floor(Math.random() * (SELF_HOST_PORT_MAX - SELF_HOST_PORT_MIN + 1));
89
+ if (await canBindLocalhostPort(candidate)) return candidate;
90
+ }
91
+ throw new Error('failed to reserve localhost port from non-ephemeral range');
92
+ }
93
+
@@ -1,38 +1,13 @@
1
1
  import assert from 'node:assert/strict';
2
- import { spawnSync } from 'node:child_process';
3
- import { chmod, cp, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises';
4
- import { createServer } from 'node:net';
2
+ import { chmod, cp, mkdir, mkdtemp, rm } from 'node:fs/promises';
5
3
  import { tmpdir } from 'node:os';
6
4
  import { join, resolve } from 'node:path';
7
5
  import test from 'node:test';
8
6
  import { fileURLToPath } from 'node:url';
9
7
 
10
- const SELF_HOST_PORT_MIN = 20_000;
11
- const SELF_HOST_PORT_MAX = 29_999;
12
- const SELF_HOST_PORT_ATTEMPTS = 40;
13
8
  const SELF_HOST_INSTALL_TIMEOUT_MS = 420_000;
14
9
 
15
- function commandExists(cmd) {
16
- return spawnSync('bash', ['-lc', `command -v ${cmd} >/dev/null 2>&1`], { stdio: 'ignore' }).status === 0;
17
- }
18
-
19
- function run(cmd, args, { cwd, env, timeoutMs = 0, allowFail = false, stdio = 'pipe' } = {}) {
20
- const result = spawnSync(cmd, args, {
21
- cwd,
22
- env: env ?? process.env,
23
- encoding: 'utf-8',
24
- stdio,
25
- timeout: timeoutMs || undefined,
26
- });
27
- const timedOut = result.error && result.error.code === 'ETIMEDOUT';
28
- if (!allowFail && (timedOut || (result.status ?? 1) !== 0)) {
29
- const stderr = String(result.stderr ?? '').trim();
30
- const stdout = String(result.stdout ?? '').trim();
31
- const reason = timedOut ? 'timed out' : `exited with status ${result.status}`;
32
- throw new Error(`[self-host-systemd] ${cmd} ${args.join(' ')} ${reason}\nstdout:\n${stdout}\nstderr:\n${stderr}`);
33
- }
34
- return result;
35
- }
10
+ import { commandExists, extractBinaryFromArtifact, reserveLocalhostPort, run, waitForHealth } from './self_host_service_e2e_harness.mjs';
36
11
 
37
12
  function runAsRoot(cmd, args, { cwd, env, timeoutMs = 0, allowFail = false, stdio = 'pipe' } = {}) {
38
13
  const mergedEnv = {
@@ -40,13 +15,14 @@ function runAsRoot(cmd, args, { cwd, env, timeoutMs = 0, allowFail = false, stdi
40
15
  ...(env ?? {}),
41
16
  };
42
17
  if (typeof process.getuid === 'function' && process.getuid() === 0) {
43
- return run(cmd, args, { cwd, env: mergedEnv, timeoutMs, allowFail, stdio });
18
+ return run(cmd, args, { label: 'self-host-systemd', cwd, env: mergedEnv, timeoutMs, allowFail, stdio });
44
19
  }
45
20
  if (!commandExists('sudo')) {
46
21
  throw new Error('[self-host-systemd] sudo is required for systemd self-host test');
47
22
  }
48
23
  const envArgs = Object.entries(mergedEnv).map(([key, value]) => `${key}=${String(value ?? '')}`);
49
24
  return run('sudo', ['-E', 'env', ...envArgs, cmd, ...args], {
25
+ label: 'self-host-systemd',
50
26
  cwd,
51
27
  env: process.env,
52
28
  timeoutMs,
@@ -55,62 +31,6 @@ function runAsRoot(cmd, args, { cwd, env, timeoutMs = 0, allowFail = false, stdi
55
31
  });
56
32
  }
57
33
 
58
- async function extractBinaryFromArtifact({ artifactPath, binaryName }) {
59
- const extractDir = await mkdtemp(join(tmpdir(), 'happier-self-host-systemd-artifact-'));
60
- run('tar', ['-xzf', artifactPath, '-C', extractDir], { timeoutMs: 30_000 });
61
- const roots = await readdir(extractDir);
62
- assert.ok(roots.length > 0, `expected extracted root directory for ${artifactPath}`);
63
- return {
64
- extractDir,
65
- binaryPath: join(extractDir, roots[0], binaryName),
66
- };
67
- }
68
-
69
- async function waitForHealth(url, timeoutMs = 60_000) {
70
- const started = Date.now();
71
- while (Date.now() - started < timeoutMs) {
72
- try {
73
- const response = await fetch(url, {
74
- headers: { accept: 'application/json' },
75
- });
76
- if (response.ok) {
77
- const payload = await response.json().catch(() => ({}));
78
- if (payload?.ok === true) {
79
- return true;
80
- }
81
- }
82
- } catch {
83
- // keep polling until timeout
84
- }
85
- await new Promise((resolve) => setTimeout(resolve, 1500));
86
- }
87
- return false;
88
- }
89
-
90
- async function canBindLocalhostPort(port) {
91
- return await new Promise((resolvePort) => {
92
- const server = createServer();
93
- server.unref();
94
- server.once('error', () => resolvePort(false));
95
- server.listen(port, '127.0.0.1', () => {
96
- server.close((error) => {
97
- resolvePort(!error);
98
- });
99
- });
100
- });
101
- }
102
-
103
- async function reserveLocalhostPort() {
104
- for (let attempt = 0; attempt < SELF_HOST_PORT_ATTEMPTS; attempt += 1) {
105
- const candidate =
106
- SELF_HOST_PORT_MIN + Math.floor(Math.random() * (SELF_HOST_PORT_MAX - SELF_HOST_PORT_MIN + 1));
107
- if (await canBindLocalhostPort(candidate)) {
108
- return candidate;
109
- }
110
- }
111
- throw new Error('failed to reserve localhost port from non-ephemeral range');
112
- }
113
-
114
34
  test(
115
35
  'compiled hstack self-host install/uninstall works on systemd host without repo checkout',
116
36
  { timeout: 15 * 60_000 },
@@ -148,6 +68,7 @@ test(
148
68
  '--targets=linux-x64',
149
69
  ],
150
70
  {
71
+ label: 'self-host-systemd',
151
72
  cwd: repoRoot,
152
73
  env: { ...process.env },
153
74
  timeoutMs: 8 * 60_000,
@@ -162,6 +83,7 @@ test(
162
83
  '--targets=linux-x64',
163
84
  ],
164
85
  {
86
+ label: 'self-host-systemd',
165
87
  cwd: repoRoot,
166
88
  env: { ...process.env },
167
89
  timeoutMs: 8 * 60_000,
@@ -171,8 +93,8 @@ test(
171
93
  const hstackArtifact = join(repoRoot, 'dist', 'release-assets', 'stack', `hstack-v${version}-linux-x64.tar.gz`);
172
94
  const serverArtifact = join(repoRoot, 'dist', 'release-assets', 'server', `happier-server-v${version}-linux-x64.tar.gz`);
173
95
 
174
- const extractedHstack = await extractBinaryFromArtifact({ artifactPath: hstackArtifact, binaryName: 'hstack' });
175
- const extractedServer = await extractBinaryFromArtifact({ artifactPath: serverArtifact, binaryName: 'happier-server' });
96
+ const extractedHstack = await extractBinaryFromArtifact({ label: 'self-host-systemd', artifactPath: hstackArtifact, binaryName: 'hstack' });
97
+ const extractedServer = await extractBinaryFromArtifact({ label: 'self-host-systemd', artifactPath: serverArtifact, binaryName: 'happier-server' });
176
98
 
177
99
  t.after(async () => {
178
100
  await rm(extractedHstack.extractDir, { recursive: true, force: true });
@@ -3,9 +3,11 @@ import { run, runCapture } from './utils/proc/proc.mjs';
3
3
  import { getComponentDir, getDefaultAutostartPaths, getRootDir, getSystemdUnitInfo, resolveStackEnvPath } from './utils/paths/paths.mjs';
4
4
  import { getInternalServerUrl, getPublicServerUrlEnvOverride } from './utils/server/urls.mjs';
5
5
  import { ensureMacAutostartDisabled, ensureMacAutostartEnabled } from './utils/service/autostart_darwin.mjs';
6
+ import { installService as installManagedService, uninstallService as uninstallManagedService } from './utils/service/service_manager.mjs';
6
7
  import { getCanonicalHomeDir } from './utils/env/config.mjs';
7
8
  import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
8
9
  import { expandHome } from './utils/paths/canonical_home.mjs';
10
+ import { resolveInstalledCliRoot, resolveInstalledPath } from './utils/paths/runtime.mjs';
9
11
  import { spawn } from 'node:child_process';
10
12
  import { homedir } from 'node:os';
11
13
  import { existsSync } from 'node:fs';
@@ -109,6 +111,33 @@ function ensureLinuxSystemModeSupported({ mode }) {
109
111
  }
110
112
  }
111
113
 
114
+ async function resolveStackAutostartProgramArgs({ rootDir, mode, systemUser }) {
115
+ const base = getCanonicalHomeDir();
116
+ const candidates =
117
+ process.platform === 'win32'
118
+ ? [join(base, 'bin', 'hstack.exe'), join(base, 'bin', 'hstack.cmd'), join(base, 'bin', 'hstack')]
119
+ : [join(base, 'bin', 'hstack')];
120
+
121
+ let shimPath = candidates.find((p) => existsSync(p)) ?? '';
122
+ if (mode === 'system' && systemUser) {
123
+ const home = await resolveHomeDirForUser(systemUser);
124
+ if (home) {
125
+ const systemCandidates =
126
+ process.platform === 'win32'
127
+ ? [
128
+ join(home, '.happier-stack', 'bin', 'hstack.exe'),
129
+ join(home, '.happier-stack', 'bin', 'hstack.cmd'),
130
+ join(home, '.happier-stack', 'bin', 'hstack'),
131
+ ]
132
+ : [join(home, '.happier-stack', 'bin', 'hstack')];
133
+ shimPath = systemCandidates.find((p) => existsSync(p)) ?? shimPath;
134
+ }
135
+ }
136
+
137
+ if (shimPath) return [shimPath, 'start'];
138
+ return [process.execPath, resolveInstalledPath(rootDir, 'bin/hstack.mjs'), 'start'];
139
+ }
140
+
112
141
  export async function installService({ mode = 'user', systemUser = null } = {}) {
113
142
  if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
114
143
  throw new Error(
@@ -118,11 +147,8 @@ export async function installService({ mode = 'user', systemUser = null } = {})
118
147
  );
119
148
  }
120
149
  ensureLinuxSystemModeSupported({ mode });
121
- if (process.platform !== 'darwin' && process.platform !== 'linux') {
122
- throw new Error('[local] service install is only supported on macOS (launchd) and Linux (systemd).');
123
- }
124
150
  const rootDir = getRootDir(import.meta.url);
125
- const { label } = getDefaultAutostartPaths();
151
+ const { label, stdoutPath, stderrPath, baseDir } = getDefaultAutostartPaths();
126
152
  const env = getAutostartEnv({ rootDir, mode });
127
153
  // Ensure the env file exists so the service never points at a missing path.
128
154
  try {
@@ -137,17 +163,43 @@ export async function installService({ mode = 'user', systemUser = null } = {})
137
163
  } catch {
138
164
  // ignore
139
165
  }
166
+ const programArgs = await resolveStackAutostartProgramArgs({ rootDir, mode, systemUser });
167
+ const workingDirectory =
168
+ process.platform === 'linux'
169
+ ? '%h'
170
+ : process.platform === 'darwin'
171
+ ? resolveInstalledCliRoot(rootDir)
172
+ : baseDir;
173
+
174
+ await installManagedService({
175
+ platform: process.platform,
176
+ mode,
177
+ homeDir: homedir(),
178
+ spec: {
179
+ label,
180
+ description: `Happier Stack (${label})`,
181
+ programArgs,
182
+ workingDirectory,
183
+ env,
184
+ runAsUser: mode === 'system' && systemUser ? systemUser : '',
185
+ stdoutPath,
186
+ stderrPath,
187
+ },
188
+ persistent: true,
189
+ });
190
+
191
+ if (process.platform === 'win32') {
192
+ console.log(`${green('✓')} service installed ${dim('(Windows scheduled task)')}`);
193
+ return;
194
+ }
140
195
  if (process.platform === 'darwin') {
141
- await ensureMacAutostartEnabled({ rootDir, label, env });
142
196
  console.log(`${green('✓')} service installed ${dim('(macOS launchd)')}`);
143
197
  return;
144
198
  }
145
199
  if (mode === 'system') {
146
- await ensureSystemdSystemServiceEnabled({ rootDir, label, env, systemUser });
147
200
  console.log(`${green('✓')} service installed ${dim('(Linux systemd system)')}`);
148
201
  return;
149
202
  }
150
- await ensureSystemdUserServiceEnabled({ rootDir, label, env });
151
203
  console.log(`${green('✓')} service installed ${dim('(Linux systemd --user)')}`);
152
204
  }
153
205
 
@@ -157,25 +209,40 @@ export async function uninstallService({ mode = 'user' } = {}) {
157
209
  return;
158
210
  }
159
211
  ensureLinuxSystemModeSupported({ mode });
160
- if (process.platform !== 'darwin' && process.platform !== 'linux') return;
212
+ const rootDir = getRootDir(import.meta.url);
213
+ const { label, stdoutPath, stderrPath, baseDir } = getDefaultAutostartPaths();
214
+ const env = getAutostartEnv({ rootDir, mode });
215
+ const programArgs = await resolveStackAutostartProgramArgs({ rootDir, mode, systemUser: null });
216
+ const workingDirectory =
217
+ process.platform === 'linux'
218
+ ? '%h'
219
+ : process.platform === 'darwin'
220
+ ? resolveInstalledCliRoot(rootDir)
221
+ : baseDir;
222
+
223
+ await uninstallManagedService({
224
+ platform: process.platform,
225
+ mode,
226
+ homeDir: homedir(),
227
+ spec: {
228
+ label,
229
+ description: `Happier Stack (${label})`,
230
+ programArgs,
231
+ workingDirectory,
232
+ env,
233
+ stdoutPath,
234
+ stderrPath,
235
+ },
236
+ persistent: true,
237
+ });
161
238
 
162
- if (process.platform === 'linux') {
163
- if (mode === 'system') {
164
- await ensureSystemdSystemServiceDisabled({ remove: true });
165
- console.log(`${green('✓')} service uninstalled ${dim('(systemd system unit removed)')}`);
166
- return;
167
- }
168
- await ensureSystemdUserServiceDisabled({ remove: true });
169
- console.log(`${green('✓')} service uninstalled ${dim('(systemd user unit removed)')}`);
239
+ if (process.platform === 'win32') {
240
+ console.log(`${green('✓')} service uninstalled ${dim('(Windows task removed)')}`);
170
241
  return;
171
242
  }
172
- const { plistPath, label } = getDefaultAutostartPaths();
173
-
174
- await ensureMacAutostartDisabled({ label });
175
- try {
176
- await rm(plistPath, { force: true });
177
- } catch {
178
- // ignore
243
+ if (process.platform === 'linux') {
244
+ console.log(`${green('✓')} service uninstalled ${dim('(systemd unit removed)')}`);
245
+ return;
179
246
  }
180
247
  console.log(`${green('✓')} service uninstalled ${dim('(plist removed)')}`);
181
248
  }
@@ -633,8 +700,8 @@ async function main() {
633
700
  const systemUser = resolveSystemUser(helpScopeArgv);
634
701
  ensureLinuxSystemModeSupported({ mode });
635
702
 
636
- if (process.platform !== 'darwin' && process.platform !== 'linux') {
637
- throw new Error('[local] service commands are only supported on macOS (launchd) and Linux (systemd).');
703
+ if (process.platform !== 'darwin' && process.platform !== 'linux' && process.platform !== 'win32') {
704
+ throw new Error('[local] service commands are only supported on macOS (launchd), Linux (systemd), and Windows (schtasks).');
638
705
  }
639
706
  const positionals = helpScopeArgv.filter((a) => a && a !== '--' && !a.startsWith('-'));
640
707
  const cmd = positionals[0] ?? 'help';
@@ -711,7 +778,17 @@ async function main() {
711
778
  health = { ok: false, status: null, body: null };
712
779
  }
713
780
 
714
- if (process.platform === 'darwin') {
781
+ if (process.platform === 'win32') {
782
+ const { label, stdoutPath, stderrPath } = getDefaultAutostartPaths();
783
+ const taskName = `Happier\\${label}`;
784
+ let schtasksStatus = null;
785
+ try {
786
+ schtasksStatus = await runCapture('schtasks', ['/Query', '/TN', taskName, '/FO', 'LIST', '/V']);
787
+ } catch (e) {
788
+ schtasksStatus = e && typeof e === 'object' && 'out' in e ? e.out : null;
789
+ }
790
+ printResult({ json, data: { label, taskName, stdoutPath, stderrPath, internalUrl, schtasksStatus, health } });
791
+ } else if (process.platform === 'darwin') {
715
792
  const { plistPath, stdoutPath, stderrPath, label } = getDefaultAutostartPaths();
716
793
  let launchctlLine = null;
717
794
  try {
@@ -736,6 +813,11 @@ async function main() {
736
813
  printResult({ json, data: { mode, unitName, unitPath, internalUrl, systemctlStatus, health } });
737
814
  }
738
815
  } else {
816
+ if (process.platform === 'win32') {
817
+ const { label } = getDefaultAutostartPaths();
818
+ await run('schtasks', ['/Query', '/TN', `Happier\\${label}`]);
819
+ return;
820
+ }
739
821
  if (process.platform === 'darwin') {
740
822
  await showStatus();
741
823
  } else {
@@ -749,6 +831,13 @@ async function main() {
749
831
  }
750
832
  return;
751
833
  case 'start':
834
+ if (process.platform === 'win32') {
835
+ const { label } = getDefaultAutostartPaths();
836
+ await run('schtasks', ['/Run', '/TN', `Happier\\${label}`]).catch(() => {});
837
+ await postStartDiagnostics();
838
+ if (json) printResult({ json, data: { ok: true, action: 'start' } });
839
+ return;
840
+ }
752
841
  if (process.platform === 'darwin') {
753
842
  await startLaunchAgent({ persistent: false });
754
843
  } else {
@@ -763,6 +852,12 @@ async function main() {
763
852
  if (json) printResult({ json, data: { ok: true, action: 'start' } });
764
853
  return;
765
854
  case 'stop':
855
+ if (process.platform === 'win32') {
856
+ const { label } = getDefaultAutostartPaths();
857
+ await run('schtasks', ['/End', '/TN', `Happier\\${label}`]).catch(() => {});
858
+ if (json) printResult({ json, data: { ok: true, action: 'stop' } });
859
+ return;
860
+ }
766
861
  if (process.platform === 'darwin') {
767
862
  await stopLaunchAgent({ persistent: false });
768
863
  } else {
@@ -776,6 +871,14 @@ async function main() {
776
871
  if (json) printResult({ json, data: { ok: true, action: 'stop' } });
777
872
  return;
778
873
  case 'restart':
874
+ if (process.platform === 'win32') {
875
+ const { label } = getDefaultAutostartPaths();
876
+ await run('schtasks', ['/End', '/TN', `Happier\\${label}`]).catch(() => {});
877
+ await run('schtasks', ['/Run', '/TN', `Happier\\${label}`]).catch(() => {});
878
+ await postStartDiagnostics();
879
+ if (json) printResult({ json, data: { ok: true, action: 'restart' } });
880
+ return;
881
+ }
779
882
  if (process.platform === 'darwin') {
780
883
  if (!(await restartLaunchAgentBestEffort())) {
781
884
  await stopLaunchAgent({ persistent: false });
@@ -794,6 +897,14 @@ async function main() {
794
897
  if (json) printResult({ json, data: { ok: true, action: 'restart' } });
795
898
  return;
796
899
  case 'enable':
900
+ if (process.platform === 'win32') {
901
+ const { label } = getDefaultAutostartPaths();
902
+ await run('schtasks', ['/Change', '/TN', `Happier\\${label}`, '/Enable']).catch(() => {});
903
+ await run('schtasks', ['/Run', '/TN', `Happier\\${label}`]).catch(() => {});
904
+ await postStartDiagnostics();
905
+ if (json) printResult({ json, data: { ok: true, action: 'enable' } });
906
+ return;
907
+ }
797
908
  if (process.platform === 'darwin') {
798
909
  await startLaunchAgent({ persistent: true });
799
910
  } else {
@@ -808,6 +919,13 @@ async function main() {
808
919
  if (json) printResult({ json, data: { ok: true, action: 'enable' } });
809
920
  return;
810
921
  case 'disable':
922
+ if (process.platform === 'win32') {
923
+ const { label } = getDefaultAutostartPaths();
924
+ await run('schtasks', ['/End', '/TN', `Happier\\${label}`]).catch(() => {});
925
+ await run('schtasks', ['/Change', '/TN', `Happier\\${label}`, '/Disable']).catch(() => {});
926
+ if (json) printResult({ json, data: { ok: true, action: 'disable' } });
927
+ return;
928
+ }
811
929
  if (process.platform === 'darwin') {
812
930
  await stopLaunchAgent({ persistent: true });
813
931
  } else {
@@ -821,6 +939,15 @@ async function main() {
821
939
  if (json) printResult({ json, data: { ok: true, action: 'disable' } });
822
940
  return;
823
941
  case 'logs':
942
+ if (process.platform === 'win32') {
943
+ const { stdoutPath, stderrPath } = getDefaultAutostartPaths();
944
+ const out = await readLastLines(stdoutPath, 200).catch(() => '');
945
+ const err = await readLastLines(stderrPath, 200).catch(() => '');
946
+ console.log(bullets([kv('stdout:', stdoutPath), kv('stderr:', stderrPath)]));
947
+ if (out.trim()) console.log(out.trimEnd());
948
+ if (err.trim()) console.log(err.trimEnd());
949
+ return;
950
+ }
824
951
  if (process.platform === 'darwin') {
825
952
  await showLogs();
826
953
  } else {
@@ -834,6 +961,9 @@ async function main() {
834
961
  }
835
962
  return;
836
963
  case 'tail':
964
+ if (process.platform === 'win32') {
965
+ throw new Error('[local] service tail is not supported on Windows yet. Use: hstack service logs');
966
+ }
837
967
  if (process.platform === 'darwin') {
838
968
  await tailLogs();
839
969
  } else {
@@ -13,6 +13,7 @@ const STACK_NAME_FIRST_SUPPORTED_COMMANDS = new Set([
13
13
  'create-dev-auth-seed',
14
14
  'daemon',
15
15
  'happier',
16
+ 'bug-report',
16
17
  'env',
17
18
  'auth',
18
19
  'dev',
@@ -10,6 +10,7 @@ const STACK_HELP_USAGE_LINES = [
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> [-- ...]',
13
+ 'hstack stack bug-report <name> [-- ...]',
13
14
  'hstack stack env <name> set KEY=VALUE [KEY2=VALUE2...] | unset KEY [KEY2...] | get KEY | list | path [--json]',
14
15
  'hstack stack auth <name> status|login|copy-from [--json]',
15
16
  'hstack stack dev <name> [-- ...]',
@@ -48,6 +49,7 @@ export const STACK_HELP_COMMANDS = [
48
49
  'daemon',
49
50
  'eas',
50
51
  'happier',
52
+ 'bug-report',
51
53
  'env',
52
54
  'auth',
53
55
  'dev',
@@ -448,6 +448,43 @@ test('hstack stack daemon <name> start uses runtime server port when env port is
448
448
  );
449
449
  });
450
450
 
451
+ test('hstack stack daemon <name> start uses explicit HAPPIER_SERVER_URL when env port and runtime port are missing', async (t) => {
452
+ const fixture = await createDaemonFixture(t, {
453
+ prefix: 'happy-stacks-stack-daemon-explicit-server-url-',
454
+ stackName: 'exp-test',
455
+ serverPort: 4101,
456
+ });
457
+
458
+ await writeDummyAuth({ cliHomeDir: fixture.stackCliHome });
459
+
460
+ const explicitPort = fixture.serverPort + 9;
461
+ const envPath = join(fixture.storageDir, fixture.stackName, 'env');
462
+ await mkdir(dirname(envPath), { recursive: true });
463
+ await writeFile(
464
+ envPath,
465
+ [
466
+ `HAPPIER_STACK_REPO_DIR=${fixture.baseEnv.HAPPIER_STACK_WORKSPACE_DIR}/happier`,
467
+ `HAPPIER_STACK_CLI_HOME_DIR=${fixture.stackCliHome}`,
468
+ `HAPPIER_SERVER_URL=http://127.0.0.1:${explicitPort}`,
469
+ `HAPPIER_WEBAPP_URL=http://happier-exp-test.localhost:${explicitPort}`,
470
+ '',
471
+ ].join('\n'),
472
+ 'utf-8'
473
+ );
474
+
475
+ registerDaemonCleanup(t, { env: fixture.baseEnv, stackName: fixture.stackName });
476
+
477
+ const startRes = await runHstack(['stack', 'daemon', fixture.stackName, 'start', '--json'], { env: fixture.baseEnv });
478
+ assertExitOk(startRes, 'stack daemon start uses explicit HAPPIER_SERVER_URL');
479
+
480
+ const logPath = join(fixture.stackCliHome, 'stub-daemon.log');
481
+ const logText = await readLogText(logPath);
482
+ assert.ok(
483
+ logText.includes(`server_url=http://127.0.0.1:${explicitPort}`),
484
+ `expected daemon env to target explicit HAPPIER_SERVER_URL port ${explicitPort}\n${logText}`
485
+ );
486
+ });
487
+
451
488
  test('hstack stack auth <name> login --identity=<name> --print prints identity-scoped HAPPIER_HOME_DIR', async (t) => {
452
489
  const fixture = await createDaemonFixture(t, {
453
490
  prefix: 'happier-stack-auth-identity-',
@@ -25,6 +25,7 @@ async function writeStubHappyCli({ cliDir, message }) {
25
25
  [
26
26
  `console.log(JSON.stringify({`,
27
27
  ` message: ${JSON.stringify(message)},`,
28
+ ` args: process.argv.slice(2),`,
28
29
  ` stack: process.env.HAPPIER_STACK_STACK || null,`,
29
30
  ` envFile: process.env.HAPPIER_STACK_ENV_FILE || null,`,
30
31
  ` homeDir: process.env.HAPPIER_HOME_DIR || null,`,
@@ -261,3 +262,38 @@ test('hstack stack <name> happier ... stack-name-first shorthand works', async (
261
262
  assert.equal(out.message, 'name-first');
262
263
  assert.equal(out.stack, fixture.stackName);
263
264
  });
265
+
266
+ test('hstack stack bug-report <name> forwards bug-report command under stack env', async (t) => {
267
+ const fixture = await createHappyStackFixture(t, {
268
+ prefix: 'happier-stack-stack-bug-report-',
269
+ message: 'bug-report-alias',
270
+ serverPort: 4099,
271
+ });
272
+
273
+ const res = await runNodeCapture(
274
+ [
275
+ join(rootDir, 'bin', 'hstack.mjs'),
276
+ 'stack',
277
+ 'bug-report',
278
+ fixture.stackName,
279
+ '--',
280
+ '--title',
281
+ 'CLI bug',
282
+ '--summary',
283
+ 'summary',
284
+ '--current-behavior',
285
+ 'current',
286
+ '--expected-behavior',
287
+ 'expected',
288
+ '--accept-privacy-notice',
289
+ '--no-include-diagnostics',
290
+ ],
291
+ { cwd: rootDir, env: fixture.baseEnv }
292
+ );
293
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
294
+
295
+ const out = JSON.parse(res.stdout.trim());
296
+ assert.equal(out.message, 'bug-report-alias');
297
+ assert.equal(out.stack, fixture.stackName);
298
+ assert.equal(out.args[0], 'bug-report');
299
+ });