@ctrl-spc/cli 1.1.0 → 1.1.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/dist/session.js CHANGED
@@ -1,17 +1,17 @@
1
1
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { dirname, join } from 'node:path';
3
+ import * as path from 'node:path';
4
4
  import { homedir } from 'node:os';
5
- export function getStatePaths(home = homedir()) {
6
- const stateDir = join(home, '.ctrl-spc');
5
+ export function getStatePaths(options) {
6
+ const { stateDir, pathApi } = resolveStateDir(options);
7
7
  return {
8
8
  stateDir,
9
- statePath: join(stateDir, 'machine.json'),
10
- daemonLogPath: join(stateDir, 'daemon.log'),
9
+ statePath: pathApi.join(stateDir, 'machine.json'),
10
+ daemonLogPath: pathApi.join(stateDir, 'daemon.log'),
11
11
  };
12
12
  }
13
- export function ensureStateDir(home = homedir()) {
14
- const { stateDir } = getStatePaths(home);
13
+ export function ensureStateDir(options) {
14
+ const { stateDir } = getStatePaths(options);
15
15
  mkdirSync(stateDir, { recursive: true });
16
16
  return stateDir;
17
17
  }
@@ -34,10 +34,23 @@ export function loadMachineState(statePath = getStatePaths().statePath) {
34
34
  }
35
35
  }
36
36
  export function saveMachineState(state, statePath = getStatePaths().statePath) {
37
- mkdirSync(dirname(statePath), { recursive: true });
37
+ mkdirSync(path.dirname(statePath), { recursive: true });
38
38
  writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
39
39
  chmodSync(statePath, 0o600);
40
40
  }
41
41
  export function getOrCreateMachineId(statePath = getStatePaths().statePath) {
42
42
  return loadMachineState(statePath)?.machineId ?? randomUUID();
43
43
  }
44
+ function resolveStateDir(options) {
45
+ if (typeof options === 'string') {
46
+ return { stateDir: path.join(options, '.ctrl-spc'), pathApi: path };
47
+ }
48
+ const platform = options?.platform ?? process.platform;
49
+ const pathApi = platform === 'win32' ? path.win32 : path;
50
+ const home = options?.home ?? homedir();
51
+ if (platform === 'win32') {
52
+ const appData = options?.appData ?? process.env.APPDATA ?? pathApi.join(home, 'AppData', 'Roaming');
53
+ return { stateDir: pathApi.join(appData, 'ctrl-spc'), pathApi };
54
+ }
55
+ return { stateDir: pathApi.join(home, '.ctrl-spc'), pathApi };
56
+ }
package/dist/setup.js CHANGED
@@ -18,11 +18,17 @@ export async function runSetup() {
18
18
  const installer = process.platform === 'darwin'
19
19
  ? await import('./launchd.js')
20
20
  : await import('./windows-service.js');
21
- console.log('Check your email for a sign-in link to connect this machine.');
22
21
  try {
23
- const state = await authenticate(email);
24
- await upsertDevice(state, hostname());
25
- await installer.installDaemon();
22
+ await completeSetup({
23
+ email,
24
+ label: hostname(),
25
+ installDaemon: installer.installDaemon,
26
+ onReadyForAuth: () => {
27
+ console.log('Check your email for a sign-in link to connect this machine.');
28
+ },
29
+ authenticate,
30
+ upsertDevice,
31
+ });
26
32
  }
27
33
  catch (err) {
28
34
  exitWithMessage(err instanceof Error ? err.message : SEND_EMAIL_FAILED_MESSAGE);
@@ -43,3 +49,9 @@ function exitWithMessage(message) {
43
49
  console.log(message);
44
50
  process.exitCode = 1;
45
51
  }
52
+ export async function completeSetup(options) {
53
+ await options.installDaemon();
54
+ options.onReadyForAuth?.();
55
+ const state = await options.authenticate(options.email);
56
+ await options.upsertDevice(state, options.label);
57
+ }
@@ -1,7 +1,107 @@
1
- const WINDOWS_UNSUPPORTED_MESSAGE = 'Automatic Windows service install is not available in this build. macOS launchd setup is available today.';
1
+ import { existsSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { homedir } from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { ensureStateDir, getStatePaths } from './session.js';
7
+ export const WINDOWS_SERVICE_NAME = 'ctrl-spc-daemon';
8
+ const INSTALL_FAILED_MESSAGE = "Couldn't install the background helper. Try again, or download the installer from the web app.";
9
+ export function resolveBinPath(moduleUrl = import.meta.url) {
10
+ return fileURLToPath(new URL('../bin/ctrl-spc.js', moduleUrl));
11
+ }
12
+ export function buildWindowsServiceConfig(options = {}) {
13
+ const userProfile = options.userProfile ?? process.env.USERPROFILE ?? homedir();
14
+ const appData = options.appData ?? process.env.APPDATA ?? path.win32.join(userProfile, 'AppData', 'Roaming');
15
+ const scriptPath = options.scriptPath ?? resolveBinPath();
16
+ const { stateDir } = getStatePaths({ platform: 'win32', home: userProfile, appData });
17
+ return {
18
+ name: WINDOWS_SERVICE_NAME,
19
+ description: 'Control Space background daemon',
20
+ script: scriptPath,
21
+ scriptOptions: 'daemon',
22
+ execPath: options.nodePath ?? process.execPath,
23
+ workingDirectory: windowsDirname(scriptPath),
24
+ logpath: stateDir,
25
+ wait: 5,
26
+ grow: 0.25,
27
+ maxRestarts: 3,
28
+ stopparentfirst: true,
29
+ stoptimeout: 30,
30
+ env: [
31
+ { name: 'APPDATA', value: appData },
32
+ { name: 'USERPROFILE', value: userProfile },
33
+ ],
34
+ };
35
+ }
2
36
  export async function installDaemon() {
3
- throw new Error(WINDOWS_UNSUPPORTED_MESSAGE);
37
+ if (process.platform !== 'win32') {
38
+ throw new Error(INSTALL_FAILED_MESSAGE);
39
+ }
40
+ const userProfile = process.env.USERPROFILE ?? homedir();
41
+ const appData = process.env.APPDATA ?? path.win32.join(userProfile, 'AppData', 'Roaming');
42
+ const stateDir = ensureStateDir({ platform: 'win32', home: userProfile, appData });
43
+ const service = createWindowsService(buildWindowsServiceConfig({ userProfile, appData }));
44
+ await installAndStartService(service, path.win32.join(stateDir, 'service'));
45
+ }
46
+ export function installAndStartService(service, serviceDir) {
47
+ return new Promise((resolve, reject) => {
48
+ let settled = false;
49
+ let startRequested = false;
50
+ const timeout = setTimeout(() => {
51
+ finish(() => reject(new Error(INSTALL_FAILED_MESSAGE)));
52
+ }, 60_000);
53
+ service.once('install', startService);
54
+ service.once('alreadyinstalled', startService);
55
+ service.once('invalidinstallation', () => finish(() => reject(new Error(INSTALL_FAILED_MESSAGE))));
56
+ service.once('start', () => finish(resolve));
57
+ service.once('error', (err) => {
58
+ if (startRequested && isAlreadyStartedError(err)) {
59
+ finish(resolve);
60
+ return;
61
+ }
62
+ finish(() => reject(new Error(INSTALL_FAILED_MESSAGE)));
63
+ });
64
+ try {
65
+ service.install(serviceDir);
66
+ }
67
+ catch {
68
+ finish(() => reject(new Error(INSTALL_FAILED_MESSAGE)));
69
+ }
70
+ function startService() {
71
+ startRequested = true;
72
+ try {
73
+ service.start();
74
+ }
75
+ catch {
76
+ finish(() => reject(new Error(INSTALL_FAILED_MESSAGE)));
77
+ }
78
+ }
79
+ function finish(done) {
80
+ if (settled)
81
+ return;
82
+ settled = true;
83
+ clearTimeout(timeout);
84
+ done();
85
+ }
86
+ });
4
87
  }
5
88
  export function isDaemonInstalled() {
6
- return false;
89
+ const userProfile = process.env.USERPROFILE ?? homedir();
90
+ const appData = process.env.APPDATA ?? path.win32.join(userProfile, 'AppData', 'Roaming');
91
+ const { stateDir } = getStatePaths({ platform: 'win32', home: userProfile, appData });
92
+ return existsSync(path.win32.join(stateDir, 'service', 'ctrlspcdaemon.exe'));
93
+ }
94
+ function createWindowsService(config) {
95
+ const require = createRequire(import.meta.url);
96
+ const { Service } = require('node-windows');
97
+ return new Service(config);
98
+ }
99
+ function isAlreadyStartedError(err) {
100
+ const message = err instanceof Error ? err.message : String(err);
101
+ return message.toLowerCase().includes('already') && message.toLowerCase().includes('started');
102
+ }
103
+ function windowsDirname(filePath) {
104
+ return /^[A-Za-z]:[\\/]/.test(filePath) || filePath.includes('\\')
105
+ ? path.win32.dirname(filePath)
106
+ : path.dirname(filePath);
7
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Control Space CLI - machine setup and background daemon",
5
5
  "type": "module",
6
6
  "files": [
@@ -18,7 +18,8 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@supabase/supabase-js": "^2.108.2"
21
+ "@supabase/supabase-js": "^2.108.2",
22
+ "node-windows": "^1.0.0-beta.8"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@types/node": "^24.13.2",