@stratusagent/cli 0.8.0 → 0.9.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/index.js CHANGED
@@ -18,15 +18,67 @@ import { loadPlugins } from '@stratusagent/plugins';
18
18
  import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, } from '@stratusagent/permissions';
19
19
  import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
20
20
  import { canonicalDestination, createForgetTool, createRecallTool, createRememberTool, defineAgent, describeCadence, describeSchedule, FORGET_TOOL_NAME, MEMORY_TOOL_NAME, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
21
- import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, } from '@stratusagent/state';
22
- import { installService, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
23
- export { installService, launchdPlist, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
21
+ import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, newerStateMessage, pendingStateMigrations, readStateStamp, runStateMigrations, STATE_SCHEMA_VERSION, } from '@stratusagent/state';
22
+ import { installService, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
23
+ export { installService, launchdPlist, readServiceCommand, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
24
24
  import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
25
25
  export { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, parseLogLines, readRecentRecords, tailLog, } from "./logs.js";
26
26
  // The shared state package owns config resolution, credentials, memory, and
27
27
  // provider wiring now (the gateway uses the same code); the CLI re-exports
28
28
  // its historical surface so existing importers keep working.
29
29
  export { createFileMemoryStore } from '@stratusagent/state';
30
+ /** How long `stratus update` waits for npm to answer a version lookup. */
31
+ export const VERSION_LOOKUP_TIMEOUT_MS = 15_000;
32
+ const defaultPackageVersionFetcher = async (packageName) => {
33
+ const { spawn } = await import('node:child_process');
34
+ return new Promise((resolve) => {
35
+ const child = spawn('npm', ['view', packageName, 'version'], {
36
+ stdio: ['ignore', 'pipe', 'pipe'],
37
+ shell: npmNeedsShell(process.platform),
38
+ });
39
+ let stdout = '';
40
+ let settled = false;
41
+ const finish = (value) => {
42
+ if (settled) {
43
+ return;
44
+ }
45
+ settled = true;
46
+ clearTimeout(timer);
47
+ resolve(value);
48
+ };
49
+ // An unreachable registry must degrade to "unknown", never hang the
50
+ // update on someone's terminal.
51
+ const timer = setTimeout(() => {
52
+ child.kill('SIGKILL');
53
+ finish(undefined);
54
+ }, VERSION_LOOKUP_TIMEOUT_MS);
55
+ timer.unref?.();
56
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
57
+ child.once('error', () => finish(undefined));
58
+ child.once('close', (code) => finish(code === 0 && stdout.trim().length > 0 ? stdout.trim() : undefined));
59
+ });
60
+ };
61
+ /**
62
+ * Dotted-numeric comparison, enough for this package's own versions:
63
+ * positive when `a` is newer than `b`. Anything unparseable in a segment
64
+ * counts as zero rather than throwing — a weird registry answer must not
65
+ * crash the update that would fix things.
66
+ */
67
+ export const compareVersions = (a, b) => {
68
+ const parse = (value) => value.trim().replace(/^v/, '').split('.').map((part) => {
69
+ const numeric = Number.parseInt(part, 10);
70
+ return Number.isInteger(numeric) ? numeric : 0;
71
+ });
72
+ const left = parse(a);
73
+ const right = parse(b);
74
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
75
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
76
+ if (difference !== 0) {
77
+ return difference;
78
+ }
79
+ }
80
+ return 0;
81
+ };
30
82
  /**
31
83
  * Whether spawning npm needs a shell on this platform.
32
84
  *
@@ -70,7 +122,7 @@ const defaultPackageInstaller = async (packages) => {
70
122
  });
71
123
  });
72
124
  };
73
- export const CLI_VERSION = '0.7.0';
125
+ export const CLI_VERSION = '0.9.1';
74
126
  const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
75
127
  /**
76
128
  * The range every package declares in its `engines` field. Two floors, not
@@ -130,6 +182,8 @@ Usage:
130
182
  stratus schedules
131
183
  stratus schedules cancel <id>
132
184
  stratus doctor
185
+ stratus update
186
+ stratus update --check
133
187
  stratus service install
134
188
  stratus service status
135
189
  stratus logs -f
@@ -190,6 +244,14 @@ Commands:
190
244
  doctor Show what a run would use right now — provider, model, soul —
191
245
  and which file or environment variable decided each, then
192
246
  flag anything that would surprise you (--format json)
247
+ update The whole upgrade dance, in the order that cannot lose
248
+ data: stop stratusd, upgrade the package from npm, run
249
+ pending state migrations, rewrite the service unit with
250
+ current node/entrypoint paths, restart. --check reports
251
+ what it would do without doing any of it (exits 1 when
252
+ something is actionable). Works offline too — an
253
+ unreachable npm skips the upgrade but still migrates and
254
+ repairs the unit
193
255
  dashboard Open the web dashboard: finds a running daemon (or starts one),
194
256
  mints a single-use sign-in link, and opens your browser at it.
195
257
  Needs @stratusagent/control-api and @stratusagent/dashboard
@@ -564,6 +626,23 @@ export const parseCommand = (argv, env = {}) => {
564
626
  }
565
627
  return { command: 'doctor', format, ...(configPath ? { configPath } : {}) };
566
628
  }
629
+ if (command === 'update') {
630
+ let check = false;
631
+ for (const token of rest) {
632
+ if (!token) {
633
+ continue;
634
+ }
635
+ if (token === '--help' || token === '-h') {
636
+ return { command: 'help' };
637
+ }
638
+ if (token === '--check') {
639
+ check = true;
640
+ continue;
641
+ }
642
+ throw new Error(`Unknown option: ${token}`);
643
+ }
644
+ return { command: 'update', check };
645
+ }
567
646
  if (command === 'service') {
568
647
  const action = rest[0];
569
648
  if (action === '--help' || action === '-h' || action === undefined) {
@@ -3500,6 +3579,19 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
3500
3579
  problems.push(`Slack tokens are stored for ${orphans.join(', ')}, which no agent matches — \`stratus serve\` skips them. `
3501
3580
  + 'Clear them from `stratus setup` → Channels.');
3502
3581
  }
3582
+ // The invisible failure doctor exists to surface: the service unit embeds
3583
+ // absolute node and entrypoint paths, and upgrading node (an nvm version
3584
+ // directory, say) leaves the unit pointing at an interpreter that no
3585
+ // longer exists. The service stops working and nothing else says so.
3586
+ const unitCommand = await readServiceCommand(serviceEnvFor(env));
3587
+ if (unitCommand?.execPath !== undefined && !(await pathExists(unitCommand.execPath))) {
3588
+ problems.push(`The service unit points at a node interpreter that no longer exists (${unitCommand.execPath}), so stratusd cannot start. `
3589
+ + 'Run `stratus update` (or `stratus service install`) to rewrite it with current paths.');
3590
+ }
3591
+ if (unitCommand?.scriptPath !== undefined && !(await pathExists(unitCommand.scriptPath))) {
3592
+ problems.push(`The service unit points at a CLI entrypoint that no longer exists (${unitCommand.scriptPath}), so stratusd cannot start. `
3593
+ + 'Run `stratus update` (or `stratus service install`) to rewrite it with current paths.');
3594
+ }
3503
3595
  return {
3504
3596
  ...(winner ? { configInUse: winner.path } : {}),
3505
3597
  configShadowed: shadowed.map((entry) => entry.path),
@@ -3638,6 +3730,7 @@ const serviceEnvFor = (env) => ({
3638
3730
  ...(env.homeDir !== undefined ? { homeDir: env.homeDir } : {}),
3639
3731
  cwd: readWorkingDirectory(env),
3640
3732
  ...(env.serviceRunner !== undefined ? { run: env.serviceRunner } : {}),
3733
+ ...(env.servicePlatform !== undefined ? { platform: env.servicePlatform } : {}),
3641
3734
  });
3642
3735
  /**
3643
3736
  * A credential that exists only in this shell cannot reach the daemon: a
@@ -3720,7 +3813,9 @@ export const runService = async (command, streams, env = {}) => {
3720
3813
  writeLine(streams.stdout, `No service manager for ${process.platform}. Run \`stratus serve\` yourself.`);
3721
3814
  return 1;
3722
3815
  }
3723
- writeLine(streams.stdout, `stratusd ${status.running ? 'running' : status.installed ? 'installed, not running' : 'not installed'}`);
3816
+ writeLine(streams.stdout, `stratusd ${status.running === undefined
3817
+ ? 'state unknown — the service manager did not answer'
3818
+ : status.running ? 'running' : status.installed ? 'installed, not running' : 'not installed'}`);
3724
3819
  writeLine(streams.stdout, ` manager ${status.platform}`);
3725
3820
  writeLine(streams.stdout, ` unit ${status.unitPath}`);
3726
3821
  if (status.detail) {
@@ -3734,7 +3829,7 @@ export const runService = async (command, streams, env = {}) => {
3734
3829
  writeLine(streams.stdout, status.installed
3735
3830
  ? ' logs stratus logs -f'
3736
3831
  : ' install stratus service install');
3737
- return status.running ? 0 : 1;
3832
+ return status.running === true ? 0 : 1;
3738
3833
  }
3739
3834
  // A service manager passes none of this shell's environment on, so a
3740
3835
  // config selected by STRATUS_CONFIG has to be baked into the unit
@@ -3796,6 +3891,227 @@ export const runService = async (command, streams, env = {}) => {
3796
3891
  }
3797
3892
  return result.ok ? 0 : 1;
3798
3893
  };
3894
+ const CLI_PACKAGE_NAME = '@stratusagent/cli';
3895
+ const pathExists = async (candidate) => {
3896
+ try {
3897
+ await stat(candidate);
3898
+ return true;
3899
+ }
3900
+ catch {
3901
+ return false;
3902
+ }
3903
+ };
3904
+ /**
3905
+ * `stratus update` — the whole upgrade dance, in the order that cannot lose
3906
+ * data: stop the service (so no daemon holds the session database while
3907
+ * state migrates), upgrade the package, run pending migrations, rewrite the
3908
+ * service unit with current paths, restart.
3909
+ *
3910
+ * The unit rewrite is the step that repairs the failure nothing else
3911
+ * surfaces: the unit embeds absolute node and entrypoint paths (a service
3912
+ * manager loads no shell profile, so it must), and upgrading node — under
3913
+ * nvm, a whole new version directory — leaves the unit pointing at an
3914
+ * interpreter that no longer exists. The service stops working and nothing
3915
+ * says so; the agents just stop answering.
3916
+ *
3917
+ * Each step degrades independently: an unreachable npm skips the version
3918
+ * check and package upgrade but still migrates and rewrites the unit —
3919
+ * which is exactly the repair the offline case needs.
3920
+ */
3921
+ const runUpdate = async (command, streams, env) => {
3922
+ const serviceEnv = serviceEnvFor(env);
3923
+ const out = (line) => writeLine(streams.stdout, line);
3924
+ const latest = await (env.packageVersionFetcher ?? defaultPackageVersionFetcher)(CLI_PACKAGE_NAME);
3925
+ const upgradeAvailable = latest !== undefined && compareVersions(latest, CLI_VERSION) > 0;
3926
+ // A stamp from a newer build makes every later step wrong, not just the
3927
+ // migration one: this build would migrate — and stop the daemon to do it —
3928
+ // against a format it does not understand. Checked here, before anything
3929
+ // has a side effect.
3930
+ const stamp = await readStateStamp(env);
3931
+ const stateNewer = stamp.schemaVersion > STATE_SCHEMA_VERSION;
3932
+ const status = await readServiceStatus(serviceEnv).catch(() => undefined);
3933
+ const unit = status?.installed ? await readServiceCommand(serviceEnv) : undefined;
3934
+ const unitNotes = [];
3935
+ if (status?.installed && unit?.execPath !== undefined) {
3936
+ if (!(await pathExists(unit.execPath))) {
3937
+ unitNotes.push(`the unit's interpreter no longer exists: ${unit.execPath} — stratusd cannot start until the unit is rewritten`);
3938
+ }
3939
+ else if (unit.execPath !== process.execPath) {
3940
+ unitNotes.push(`the unit runs ${unit.execPath}; this shell runs ${process.execPath}`);
3941
+ }
3942
+ if (unit.scriptPath !== undefined && !(await pathExists(unit.scriptPath))) {
3943
+ unitNotes.push(`the unit's entrypoint no longer exists: ${unit.scriptPath}`);
3944
+ }
3945
+ else if (unit.scriptPath !== undefined && process.argv[1] !== undefined && unit.scriptPath !== process.argv[1]) {
3946
+ // A versioned package directory can keep the old file alive after an
3947
+ // upgrade — the daemon then runs the old CLI indefinitely with
3948
+ // nothing missing on disk to notice.
3949
+ unitNotes.push(`the unit runs entrypoint ${unit.scriptPath}; this shell runs ${process.argv[1]}`);
3950
+ }
3951
+ }
3952
+ const pending = await pendingStateMigrations(env);
3953
+ out(`stratus ${CLI_VERSION}`);
3954
+ out(latest === undefined
3955
+ ? ' latest unknown — npm did not answer'
3956
+ : ` latest ${latest}${upgradeAvailable ? ' — update available' : ' — up to date'}`);
3957
+ out(` state schema ${stamp.schemaVersion}${stateNewer
3958
+ ? ` — written by a NEWER build than this one (which understands ${STATE_SCHEMA_VERSION})`
3959
+ : `, ${pending.length === 0 ? 'no pending migrations' : `${pending.length} pending migration${pending.length === 1 ? '' : 's'}`}`}`);
3960
+ out(` service ${status === undefined
3961
+ ? `no service manager for ${process.platform}`
3962
+ : status.installed
3963
+ ? (status.running === undefined ? 'installed, state unknown' : status.running ? 'installed, running' : 'installed, not running')
3964
+ : 'not installed'}`);
3965
+ for (const note of unitNotes) {
3966
+ out(` unit ${note}`);
3967
+ }
3968
+ if (command.check) {
3969
+ for (const migration of pending) {
3970
+ out(` pending ${migration.id} — ${migration.description}`);
3971
+ }
3972
+ if (stateNewer) {
3973
+ out('This build cannot update that state — upgrade the package itself (`npm install -g @stratusagent/cli`).');
3974
+ return 1;
3975
+ }
3976
+ const actionable = upgradeAvailable || pending.length > 0 || unitNotes.length > 0;
3977
+ out(actionable
3978
+ ? 'Run `stratus update` to apply the above.'
3979
+ : 'Nothing to do.');
3980
+ // Actionable exits 1, so a cron job or script can notice.
3981
+ return actionable ? 1 : 0;
3982
+ }
3983
+ if (stateNewer) {
3984
+ writeLine(streams.stderr, newerStateMessage(stamp.schemaVersion));
3985
+ return 1;
3986
+ }
3987
+ if (status?.installed && (status.runAtLogin === undefined || status.running === undefined)) {
3988
+ // The rewrite has to re-state the login setting and restore the prior
3989
+ // run state, and the manager could not say what either currently is.
3990
+ // Guessing would let a transient status failure convert a deliberate
3991
+ // --no-login install, or rewrite-and-stop a daemon that was actually
3992
+ // running — refuse instead, before anything has been stopped.
3993
+ writeLine(streams.stderr, `Not updating: whether stratusd ${status.running === undefined ? 'is running' : 'starts at login'} could not be determined (the service manager did not answer), and the unit rewrite would have to guess. Check \`stratus service status\` and retry.`);
3994
+ return 1;
3995
+ }
3996
+ const wasRunning = status?.running === true;
3997
+ if (wasRunning) {
3998
+ // No daemon may hold the session database while state migrates — this
3999
+ // bracket is the one place an update could otherwise lose data.
4000
+ out('Stopping stratusd for the update…');
4001
+ const stopped = await stopService(serviceEnv);
4002
+ for (const message of stopped.messages) {
4003
+ writeLine(stopped.ok ? streams.stdout : streams.stderr, message);
4004
+ }
4005
+ if (!stopped.ok) {
4006
+ writeLine(streams.stderr, 'Not updating while the daemon may still be running.');
4007
+ return 1;
4008
+ }
4009
+ }
4010
+ let upgradeFailed = false;
4011
+ if (upgradeAvailable) {
4012
+ out(`Upgrading ${CLI_PACKAGE_NAME} ${CLI_VERSION} → ${latest}…`);
4013
+ const installed = await (env.packageInstaller ?? defaultPackageInstaller)([`${CLI_PACKAGE_NAME}@latest`]);
4014
+ if (installed.ok) {
4015
+ // This process is still the old build; migrations the new version
4016
+ // adds run when it first starts — which the restart below is.
4017
+ out('Upgraded. Migrations the new version adds run on its first start.');
4018
+ }
4019
+ else {
4020
+ upgradeFailed = true;
4021
+ writeLine(streams.stderr, `npm install failed: ${installed.message || 'unknown error'} — continuing with migrations and the unit rewrite.`);
4022
+ }
4023
+ }
4024
+ else {
4025
+ out(latest === undefined
4026
+ ? 'Skipping the package upgrade — npm did not answer.'
4027
+ : 'Package already up to date.');
4028
+ }
4029
+ let applied;
4030
+ try {
4031
+ applied = await runStateMigrations(env);
4032
+ }
4033
+ catch (error) {
4034
+ // A daemon stopped for an update that then failed must not stay down:
4035
+ // the old unit is still in place (the rewrite has not happened), so
4036
+ // restarting restores the world the update found. The failure is still
4037
+ // a failure — but an offline fleet on top of it is not.
4038
+ writeLine(streams.stderr, `State migration failed: ${error instanceof Error ? error.message : String(error)}`);
4039
+ if (wasRunning) {
4040
+ const restarted = await startService(serviceEnv);
4041
+ for (const message of restarted.messages) {
4042
+ writeLine(restarted.ok ? streams.stdout : streams.stderr, message);
4043
+ }
4044
+ writeLine(streams.stderr, restarted.ok
4045
+ ? 'stratusd was restarted on its previous unit. Fix the migration failure and run `stratus update` again.'
4046
+ : 'stratusd could not be restarted either — bring it back with `stratus service start` once the failure is fixed.');
4047
+ }
4048
+ return 1;
4049
+ }
4050
+ if (applied.length === 0) {
4051
+ out('No pending state migrations.');
4052
+ }
4053
+ for (const migration of applied) {
4054
+ out(`Migrated: ${migration.description}${migration.detail !== undefined ? ` — ${migration.detail}` : ''}`);
4055
+ }
4056
+ if (status?.installed) {
4057
+ out('Rewriting the service unit with current node and entrypoint paths…');
4058
+ // The rewrite overwrites the unit before it bootstraps, so a failure
4059
+ // in between must be able to put the old definition back — otherwise
4060
+ // a stopped daemon is left with neither unit to start from.
4061
+ const previousUnit = await readFile(serviceUnitPath(serviceEnv), 'utf8').catch(() => undefined);
4062
+ const install = await installService(
4063
+ // The unit's own working directory survives the rewrite: relative
4064
+ // paths in the pinned config (a `soul`) resolve against it, so a
4065
+ // rewrite run from some other directory must not substitute its own.
4066
+ unit?.workingDirectory !== undefined ? { ...serviceEnv, cwd: unit.workingDirectory } : serviceEnv, {
4067
+ ...(status.runAtLogin === false ? { runAtLogin: false } : {}),
4068
+ // The config the existing unit was pinned to survives the rewrite —
4069
+ // a unit rewritten onto a different roster is its own outage.
4070
+ ...(unit?.configPath !== undefined ? { configPath: unit.configPath } : {}),
4071
+ });
4072
+ for (const message of install.messages) {
4073
+ writeLine(install.ok ? streams.stdout : streams.stderr, message);
4074
+ }
4075
+ if (!install.ok) {
4076
+ if (wasRunning) {
4077
+ // The fleet was up when the update began and must not stay down
4078
+ // over a failed rewrite: restore the previous unit definition and
4079
+ // start it again. The exit code stays 1 — the update failed — but
4080
+ // the world it found is put back.
4081
+ if (previousUnit !== undefined) {
4082
+ await writeFile(serviceUnitPath(serviceEnv), previousUnit, { mode: 0o644 }).catch((error) => {
4083
+ writeLine(streams.stderr, `Could not restore the previous unit either: ${error instanceof Error ? error.message : String(error)}`);
4084
+ });
4085
+ }
4086
+ const restarted = await startService(serviceEnv);
4087
+ for (const message of restarted.messages) {
4088
+ writeLine(restarted.ok ? streams.stdout : streams.stderr, message);
4089
+ }
4090
+ writeLine(streams.stderr, restarted.ok
4091
+ ? 'The unit rewrite failed, so stratusd was restarted on its previous unit. Fix the failure above and run `stratus update` again.'
4092
+ : 'The unit rewrite failed AND stratusd could not be restarted — bring it back with `stratus service start`, or `stratus service install` to rewrite the unit by hand.');
4093
+ }
4094
+ return 1;
4095
+ }
4096
+ if (!wasRunning) {
4097
+ // installService starts the daemon; a service that was deliberately
4098
+ // stopped before the update stays that way.
4099
+ const stopped = await stopService(serviceEnv);
4100
+ if (!stopped.ok) {
4101
+ // The update itself succeeded, but the user's prior state was not
4102
+ // restored: an intentionally stopped daemon is now running. That
4103
+ // is a failure a script must see, not a footnote.
4104
+ writeLine(streams.stderr, 'stratusd was not running before the update, and stopping it again failed — it is now RUNNING. Stop it with `stratus service stop`.');
4105
+ return 1;
4106
+ }
4107
+ out('stratusd was not running before the update, so it was left stopped.');
4108
+ }
4109
+ }
4110
+ else if (status !== undefined) {
4111
+ out('No service installed — nothing to rewrite. `stratus service install` sets one up.');
4112
+ }
4113
+ return upgradeFailed ? 1 : 0;
4114
+ };
3799
4115
  /**
3800
4116
  * What a `skill add` source string means, in order: a directory on this
3801
4117
  * machine, a GitHub `owner/repo` shorthand, or a URL git can clone. The
@@ -4840,6 +5156,55 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
4840
5156
  writeLine(streams.stdout, HELP_TEXT);
4841
5157
  return 0;
4842
5158
  }
5159
+ // Migrations run on first use of a newer build — every command, every
5160
+ // install path — not only via `stratus update`: state that migrates
5161
+ // only sometimes is worse than state that never migrates, because the
5162
+ // two populations diverge silently. `update` is excluded because it
5163
+ // owns the migration step at its own place in the upgrade sequence
5164
+ // (after the service stop and the package upgrade), and `--check` has
5165
+ // to be able to report what is pending rather than having just done it.
5166
+ if (command.command !== 'update') {
5167
+ const stamp = await readStateStamp(resolvedEnv);
5168
+ if (stamp.schemaVersion > STATE_SCHEMA_VERSION) {
5169
+ // Anything that writes under ~/.stratus refuses, not only the
5170
+ // daemon: a downgraded build's setup, chat, or run can discard
5171
+ // fields and invariants the newer format relies on — the exact
5172
+ // hazard the stamp exists to close. Read-only commands warn and
5173
+ // continue, because reading logs or the roster is how someone
5174
+ // diagnoses their way OUT of this state; so do `service stop`,
5175
+ // `status`, and `uninstall`, for the same reason. (`agent new`
5176
+ // only prints an identity — it writes nothing.)
5177
+ const writesState = command.command === 'serve'
5178
+ || command.command === 'setup'
5179
+ || command.command === 'chat'
5180
+ || command.command === 'run'
5181
+ || command.command === 'skill-add'
5182
+ || command.command === 'dashboard'
5183
+ || (command.command === 'schedules' && command.action === 'cancel')
5184
+ || (command.command === 'service' && (command.action === 'install' || command.action === 'start'));
5185
+ if (writesState) {
5186
+ writeLine(streams.stderr, newerStateMessage(stamp.schemaVersion));
5187
+ writeLine(streams.stderr, `Refusing \`stratus ${command.command}\` — it writes state the newer format owns. Read-only commands (logs, agents, doctor, service status/stop) still work.`);
5188
+ return 1;
5189
+ }
5190
+ writeLine(streams.stderr, `Warning: ${newerStateMessage(stamp.schemaVersion)}`);
5191
+ }
5192
+ else {
5193
+ try {
5194
+ for (const migration of await runStateMigrations(resolvedEnv)) {
5195
+ if (migration.detail !== undefined) {
5196
+ writeLine(streams.stderr, `state migration ${migration.id}: ${migration.detail}`);
5197
+ }
5198
+ }
5199
+ }
5200
+ catch (error) {
5201
+ // A failed migration must not brick every command, but running
5202
+ // on unmigrated state is worth a line: silence here is how the
5203
+ // migrated and unmigrated populations diverge.
5204
+ writeLine(streams.stderr, `Warning: state migration failed (${error instanceof Error ? error.message : String(error)}). Continuing on unmigrated state — \`stratus update\` retries it.`);
5205
+ }
5206
+ }
5207
+ }
4843
5208
  // Every handler is awaited, never bare-returned: a bare `return
4844
5209
  // promise` inside try/catch settles the async function before the
4845
5210
  // catch can see it, so a command that fails at runtime — a gateway
@@ -4869,6 +5234,9 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
4869
5234
  if (command.command === 'service') {
4870
5235
  return await runService(command, streams, resolvedEnv);
4871
5236
  }
5237
+ if (command.command === 'update') {
5238
+ return await runUpdate(command, streams, resolvedEnv);
5239
+ }
4872
5240
  if (command.command === 'chat') {
4873
5241
  return await runChat(command, streams, resolvedEnv);
4874
5242
  }