@dsh-cc/cli 0.7.0 → 0.7.1-rc.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/bin/dsh-cc.js +24 -3
- package/bootstrap.mjs +135 -1
- package/package.json +1 -1
package/bin/dsh-cc.js
CHANGED
|
@@ -9,7 +9,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { dirname, join } from 'node:path'
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
12
|
-
import { bootstrapCommand, devStoreRestoreDecision, dshUnavailableMessage, existingWorktreeDecision, formatVersionLabel, interceptResume, parseLocalConfig, parseWorktreeFlag, parseWorktreeRef, planWorktree, planWorktreeRef, prFetchRefs, remoteHost, PROFILE, readBuildInfo, repoRootFromCommonDir, runStoreRestore, sanitizeInheritedEnv, slugRetryDecision, spawnEnv, symlinkedPath, versionGate, worktreeAddArgv, worktreeEnv, worktreeIdentityRefusal } from '../bootstrap.mjs'
|
|
12
|
+
import { bootstrapCommand, BOOTSTRAP_STAMP, devStoreRestoreDecision, dshUnavailableMessage, existingWorktreeDecision, formatVersionLabel, healDecision, interceptResume, parseLocalConfig, parseWorktreeFlag, parseWorktreeRef, planWorktree, planWorktreeRef, prFetchRefs, readBootstrapVersion, remoteHost, PROFILE, readBuildInfo, repoRootFromCommonDir, runStoreHeal, runStoreRestore, sanitizeInheritedEnv, slugRetryDecision, spawnEnv, symlinkedPath, versionGate, worktreeAddArgv, worktreeEnv, worktreeIdentityRefusal, writeBootstrapStamp } from '../bootstrap.mjs'
|
|
13
13
|
import { readWorktreeSettings, resolveBaseRef, sweepWorktrees, SWEEP_CAP_MS, worktreeReuseReset, worktreeSettingsPaths } from '../worktree-lifecycle.mjs'
|
|
14
14
|
|
|
15
15
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
@@ -27,7 +27,8 @@ if (process.argv.includes('--version') || process.argv.includes('-V')) {
|
|
|
27
27
|
console.log(formatVersionLabel(ownVersion, buildInfo))
|
|
28
28
|
process.exit(0)
|
|
29
29
|
}
|
|
30
|
-
const
|
|
30
|
+
const profileExisted = existsSync(join(profileDir, 'package.json'))
|
|
31
|
+
const add = bootstrapCommand(profileExisted, ownVersion)
|
|
31
32
|
if (add !== undefined) {
|
|
32
33
|
console.error(`dsh-cc: initializing profile "${PROFILE}"…`)
|
|
33
34
|
// Minimum-version gate runs ONLY here (bootstrap/install path), never on
|
|
@@ -51,13 +52,33 @@ if (add !== undefined) {
|
|
|
51
52
|
console.error(`dsh-cc: plugin install failed. Retry:\n dsh ${add.join(' ')}`)
|
|
52
53
|
process.exit(installed.status ?? 1)
|
|
53
54
|
}
|
|
55
|
+
// First install of this launcher version: record it so later launches can
|
|
56
|
+
// tell "converged" from "installed before the heal mechanism existed".
|
|
57
|
+
writeBootstrapStamp(join(profileDir, BOOTSTRAP_STAMP), ownVersion)
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
// A dev-synced profile paired with a DIFFERENT launcher version converges
|
|
57
61
|
// back to store bundles before the session starts (plan 2026-09-13 §3.4).
|
|
58
62
|
// A fresh profile has no stamp, so this can never fire on the bootstrap path.
|
|
63
|
+
// A successful restore IS this version's install: stamp afterwards.
|
|
59
64
|
const restorePlan = devStoreRestoreDecision(buildInfo, ownVersion)
|
|
60
|
-
if (restorePlan !== null)
|
|
65
|
+
if (restorePlan !== null) {
|
|
66
|
+
const restored = runStoreRestore(profileDir, ownVersion)
|
|
67
|
+
if (restored.restored) writeBootstrapStamp(join(profileDir, BOOTSTRAP_STAMP), ownVersion)
|
|
68
|
+
} else {
|
|
69
|
+
// A pre-existing store profile converges to this launcher's version on
|
|
70
|
+
// version change (or on a missing stamp — installs made before the heal
|
|
71
|
+
// existed, including every 0.7.0-broken profile). Never fires on a
|
|
72
|
+
// dev-synced profile (that pairing belongs to the restore path) nor on a
|
|
73
|
+
// launch that just bootstrapped.
|
|
74
|
+
const heal = healDecision({
|
|
75
|
+
profileExists: profileExisted && add === undefined,
|
|
76
|
+
stampVersion: readBootstrapVersion(join(profileDir, BOOTSTRAP_STAMP)),
|
|
77
|
+
ownVersion,
|
|
78
|
+
buildInfo,
|
|
79
|
+
})
|
|
80
|
+
if (heal !== null) runStoreHeal(profileDir, ownVersion, { from: heal.from })
|
|
81
|
+
}
|
|
61
82
|
|
|
62
83
|
// A parent dsh-cc TUI process leaks DSH_CC_RESUME_SESSION / DSH_CC_AUTO_RESUME
|
|
63
84
|
// / DSH_CC_CONTINUE into a child launcher's environment. Strip them up front —
|
package/bootstrap.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { spawnSync } from 'node:child_process'
|
|
7
|
-
import { closeSync, existsSync, lstatSync, openSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
|
|
7
|
+
import { closeSync, existsSync, lstatSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
8
8
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
9
9
|
|
|
10
10
|
export const PROFILE = 'tui'
|
|
@@ -815,3 +815,137 @@ export function runStoreRestore(profileDir, ownVersion, deps = {}) {
|
|
|
815
815
|
if (locked) tryRm(lock, false)
|
|
816
816
|
}
|
|
817
817
|
}
|
|
818
|
+
|
|
819
|
+
// --- store heal: converge an existing profile after a launcher update --------
|
|
820
|
+
|
|
821
|
+
/** Marker file recording the launcher version that last installed the bundles. */
|
|
822
|
+
export const BOOTSTRAP_STAMP = '.dsh-cc-bootstrap.json'
|
|
823
|
+
/** Exclusive lock for the heal path (same freshness semantics as the restore lock). */
|
|
824
|
+
export const HEAL_LOCK = '.dsh-cc-heal.lock'
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Read the launcher version recorded by the last successful bundle install.
|
|
828
|
+
* Missing/malformed stamp -> null (pre-heal profiles, i.e. every install made
|
|
829
|
+
* before this mechanism existed) — that is a HEAL, not a skip: those profiles
|
|
830
|
+
* carry whatever the first bootstrap pinned, forever.
|
|
831
|
+
*
|
|
832
|
+
* @param {string} stampPath
|
|
833
|
+
* @returns {string | null}
|
|
834
|
+
*/
|
|
835
|
+
export function readBootstrapVersion(stampPath) {
|
|
836
|
+
try {
|
|
837
|
+
const value = JSON.parse(readFileSync(stampPath, 'utf8'))
|
|
838
|
+
return typeof value.launcherVersion === 'string' && value.launcherVersion.length > 0
|
|
839
|
+
? value.launcherVersion
|
|
840
|
+
: null
|
|
841
|
+
} catch {
|
|
842
|
+
return null
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Decide whether an EXISTING profile needs its store bundles re-added at this
|
|
848
|
+
* launcher's version. Skipped for dev pairings (channel `dev`, regardless of
|
|
849
|
+
* version match): the registry `plugin add` here would clobber the
|
|
850
|
+
* workspace-symlinked dev scope — dev convergence is `runStoreRestore`'s job
|
|
851
|
+
* alone. The first-install path is separate (`bootstrapCommand`), so callers
|
|
852
|
+
* must not heal on a launch that just bootstrapped.
|
|
853
|
+
*
|
|
854
|
+
* @param {{ profileExists: boolean, stampVersion: string | null, ownVersion: string, buildInfo?: Record<string, unknown> | null }} input
|
|
855
|
+
* @returns {{ from: string } | null}
|
|
856
|
+
*/
|
|
857
|
+
export function healDecision({ profileExists, stampVersion, ownVersion, buildInfo = null }) {
|
|
858
|
+
if (!profileExists) return null
|
|
859
|
+
if (buildInfo?.channel === 'dev') return null
|
|
860
|
+
if (stampVersion === ownVersion) return null
|
|
861
|
+
return { from: stampVersion ?? 'unknown (pre-heal profile)' }
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Write the bootstrap stamp. A failure here is reported LOUDLY, not swallowed:
|
|
866
|
+
* a launcher that heals but cannot record it re-runs a full network install on
|
|
867
|
+
* every single launch — silent degradation is unacceptable.
|
|
868
|
+
*
|
|
869
|
+
* @param {string} stampPath
|
|
870
|
+
* @param {string} ownVersion
|
|
871
|
+
* @param {(...a: unknown[]) => void} [log]
|
|
872
|
+
*/
|
|
873
|
+
export function writeBootstrapStamp(stampPath, ownVersion, log = console.error) {
|
|
874
|
+
try {
|
|
875
|
+
writeFileSync(stampPath, `${JSON.stringify({ launcherVersion: ownVersion }, null, 2)}\n`)
|
|
876
|
+
} catch (error) {
|
|
877
|
+
log(`dsh-cc: CRITICAL: profile healed to ${ownVersion} but the stamp could not be written `
|
|
878
|
+
+ `(${/** @type {Error} */ (error).message}); the full plugin install will repeat on every launch until this is fixed`)
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Re-run the bundle add against an existing profile (launcher update path).
|
|
884
|
+
* `pnpm add` upserts the pinned ranges and `dsh plugin add` reconciles
|
|
885
|
+
* `dsh.profile.bundles` idempotently — the same command surface the first
|
|
886
|
+
* bootstrap and `runStoreRestore` rely on. A failed add warns and continues
|
|
887
|
+
* launching (the stale stamp retries next launch); a failed stamp write is
|
|
888
|
+
* loud (see writeBootstrapStamp).
|
|
889
|
+
*
|
|
890
|
+
* @param {string} profileDir
|
|
891
|
+
* @param {string} ownVersion
|
|
892
|
+
* @param {{ from?: string, spawnSyncImpl?: typeof import('node:child_process').spawnSync, log?: (...a: unknown[]) => void, now?: () => number }} [options]
|
|
893
|
+
* @returns {{ healed: boolean, reason?: 'locked' | 'plugin-add-failed' }}
|
|
894
|
+
*/
|
|
895
|
+
export function runStoreHeal(profileDir, ownVersion, options = {}) {
|
|
896
|
+
const { from, spawnSyncImpl = spawnSync, log = console.error, now = Date.now } = options
|
|
897
|
+
const lock = join(profileDir, HEAL_LOCK)
|
|
898
|
+
const stamp = join(profileDir, BOOTSTRAP_STAMP)
|
|
899
|
+
const home = dirname(dirname(profileDir))
|
|
900
|
+
const profileName = basename(profileDir)
|
|
901
|
+
let locked = false
|
|
902
|
+
|
|
903
|
+
try {
|
|
904
|
+
// Exclusive create; a lock younger than ten minutes means another launch
|
|
905
|
+
// is mid-heal — launch as-is rather than race pnpm on one profile dir.
|
|
906
|
+
try {
|
|
907
|
+
closeSync(openSync(lock, 'wx'))
|
|
908
|
+
locked = true
|
|
909
|
+
} catch (error) {
|
|
910
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'EEXIST') throw error
|
|
911
|
+
let fresh = true
|
|
912
|
+
try {
|
|
913
|
+
fresh = now() - statSync(lock).mtimeMs < 10 * 60 * 1000
|
|
914
|
+
} catch { /* lock vanished between EEXIST and stat: proceed */ }
|
|
915
|
+
if (fresh) {
|
|
916
|
+
log('dsh-cc: profile heal already in progress, skipping')
|
|
917
|
+
return { healed: false, reason: 'locked' }
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
rmSync(lock, { force: true })
|
|
921
|
+
closeSync(openSync(lock, 'wx'))
|
|
922
|
+
locked = true
|
|
923
|
+
} catch {
|
|
924
|
+
log('dsh-cc: profile heal already in progress, skipping')
|
|
925
|
+
return { healed: false, reason: 'locked' }
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const result = spawnSyncImpl('dsh', ['plugin', '--profile', profileName, 'add', ...BUNDLES.map(n => `${n}@${ownVersion}`)], {
|
|
930
|
+
stdio: 'inherit',
|
|
931
|
+
env: spawnEnv(sanitizeInheritedEnv(process.env), home),
|
|
932
|
+
})
|
|
933
|
+
const success = !result.error && result.status === 0
|
|
934
|
+
if (!success) {
|
|
935
|
+
log(`dsh-cc: profile heal to ${ownVersion} failed (will retry on next launch; if the new dsh-cc version was just published, npm/pnpm's minimum-release-age window may still be hiding it)`)
|
|
936
|
+
return { healed: false, reason: 'plugin-add-failed' }
|
|
937
|
+
}
|
|
938
|
+
writeBootstrapStamp(stamp, ownVersion, log)
|
|
939
|
+
log(`dsh-cc: launcher updated ${from ?? 'unknown'} → ${ownVersion}; re-installed store bundles in profile "${profileName}"`)
|
|
940
|
+
return { healed: true }
|
|
941
|
+
} catch (error) {
|
|
942
|
+
log(`dsh-cc: profile heal failed unexpectedly: ${/** @type {Error} */ (error).message}`)
|
|
943
|
+
return { healed: false, reason: 'plugin-add-failed' }
|
|
944
|
+
} finally {
|
|
945
|
+
if (locked) {
|
|
946
|
+
try {
|
|
947
|
+
rmSync(lock, { force: true })
|
|
948
|
+
} catch { /* best-effort; a stale lock is taken over next launch */ }
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|