@gamecrate/cli 1.2.0 → 1.4.0

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/lib.js CHANGED
@@ -263,6 +263,7 @@ var root = obj({
263
263
  plugins: strArray.optional(),
264
264
  dataRoot: str,
265
265
  defaults: obj({ settings: settings.optional() }).optional(),
266
+ steamcmd: obj({ path: str.optional() }).optional(),
266
267
  games: z.unknown()
267
268
  });
268
269
  function isObj(v) {
@@ -8,6 +8,8 @@ export interface ModsContext {
8
8
  cwd: string;
9
9
  /** The resolved global config path, or where to create one. Injectable for the same reason. */
10
10
  globalPath: string;
11
+ /** What `checkDrift` asks steam with. Injectable so tests never reach the network. */
12
+ fetch?: typeof fetch;
11
13
  }
12
14
  /** The same fallback loadConfig uses, so an error names the file `config edit` would open. */
13
15
  export declare function globalConfigPath(): Promise<string>;
@@ -1,5 +1,5 @@
1
1
  import type { GamePlugin } from '../plugin';
2
- import type { GameConfig, LaunchPlan, ModIndex, ParsedArgs, Problem, RootConfig } from '../types';
2
+ import type { LaunchPlan, ModIndex, ParsedArgs, Problem, RootConfig } from '../types';
3
3
  export interface ResolveOptions {
4
4
  game: string;
5
5
  profile: string;
@@ -12,12 +12,10 @@ export interface ResolveOptions {
12
12
  cwd?: string;
13
13
  /** Lowercased packageId to the clone directory prepared for its git pin. */
14
14
  sources?: ReadonlyMap<string, string>;
15
+ /** Workshop ids a real launch would fetch, which this command did not. */
16
+ unfetched?: string[];
15
17
  }
16
- /**
17
- * doctor resolves the modless profile, so no mod ref is ever resolved there. Read the config
18
- * instead: library pins, plus every profile's mods in both the object and bare-string forms.
19
- */
20
- export declare function workshopRootProblem(gameName: string, game: GameConfig): Problem | null;
18
+ export declare function notFetched(id: string): string;
21
19
  export declare function resolvePlan(options: ResolveOptions): Promise<{
22
20
  plan: LaunchPlan;
23
21
  problems: Problem[];
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Valve's KeyValues text format, as Steam writes it to `appworkshop_<appid>.acf`. Steam owns
3
+ * that file, so every failure here degrades to an empty result instead of throwing.
4
+ */
5
+ export type AcfNode = {
6
+ [key: string]: string | AcfNode;
7
+ };
8
+ type Installed = {
9
+ manifest: string;
10
+ timeupdated: number;
11
+ };
12
+ /** Parses KeyValues text. A malformed file is an empty object, never a throw. */
13
+ export declare function parseAcf(text: string): AcfNode;
14
+ /**
15
+ * The installed workshop items, keyed by item id. Manifest ids stay strings: they run past
16
+ * Number.MAX_SAFE_INTEGER and parsing one as a number corrupts it silently.
17
+ */
18
+ export declare function installedItems(text: string): Map<string, Installed>;
19
+ export {};
@@ -5,6 +5,11 @@ import type { GameConfig, ModIndex, ModRecord, Problem, WorktreeRequest } from '
5
5
  * routinely named `[KV] Mod Manager`, so brackets and braces must not be glob syntax.
6
6
  */
7
7
  export declare function globMatch(pattern: string, path: string): boolean;
8
+ /**
9
+ * Content hash across every download root, mtime for `game.workshopRoot`. The steam client owns
10
+ * that one and leaves it alone when idle, and an unreadable acf there still means no caching.
11
+ */
12
+ export declare function workshopStamp(game: GameConfig, dataRoot: string | undefined): string | null;
8
13
  /**
9
14
  * Stamps every record inside a requested worktree, then scans the worktree itself so a tree
10
15
  * no scanRoot reaches still contributes. Only a caller who stood in, typed, or exported a
@@ -19,10 +24,10 @@ export declare function applyWorktreeRequests(index: ModIndex, requests: Worktre
19
24
  export declare function applySourceOverrides(index: ModIndex, overrides: string[], config: GameConfig): Promise<Problem[]>;
20
25
  /**
21
26
  * Scans the game install, then every scan root in declaration order, then the source cache,
22
- * then the workshop root. Local roots rescan every launch; only the workshop scan is cached,
23
- * against the acf stamp.
27
+ * then the workshop roots. Local roots rescan every launch; only the workshop scan is cached,
28
+ * against the acf stamps. Without `dataRoot` there is no download root to scan.
24
29
  */
25
- export declare function buildIndex(game: string, config: GameConfig, plugin: GamePlugin, sourcesDir?: string): Promise<ModIndex>;
30
+ export declare function buildIndex(game: string, config: GameConfig, plugin: GamePlugin, sourcesDir?: string, dataRoot?: string): Promise<ModIndex>;
26
31
  /**
27
32
  * `path:` and `workshop:` are explicit; a bare string resolves as exact packageId, then the
28
33
  * game's alias map, then a CLI-only short name. Ambiguity that the ladder cannot break is fatal.
@@ -1,4 +1,4 @@
1
- import type { GameConfig, LibraryEntry, ParsedArgs } from '../types';
1
+ import type { GameConfig, LibraryEntry, ModEntry, ParsedArgs, ProfileConfig } from '../types';
2
2
  export type GitRef = {
3
3
  kind: 'branch' | 'tag' | 'commit';
4
4
  value: string;
@@ -29,7 +29,7 @@ export declare function gitRefOf(pin: {
29
29
  }): GitRef | undefined;
30
30
  export declare function defaultBranch(url: string): GitRef;
31
31
  export declare function ensureClone(dataRoot: string, pin: GitPin, ref: GitRef, mode: SyncMode): Promise<SyncResult>;
32
- export declare function lockClone(dir: string): Promise<() => Promise<void>>;
32
+ export declare function lockDir(dir: string): Promise<() => Promise<void>>;
33
33
  export declare function unlinkOrphan(path: string, seen: string): Promise<boolean>;
34
34
  export interface PreparedSources {
35
35
  /** lowercased packageId -> clone directory. Handed to resolvePlan as `sources`. */
@@ -40,6 +40,11 @@ export interface PreparedSources {
40
40
  /** Released by execute(), and by run()'s finally. Never before buildLocalMods. */
41
41
  release: () => Promise<void>;
42
42
  }
43
+ /**
44
+ * Every entry a launch of this profile reaches, in collectSlots order, minus what `exclude` and
45
+ * `--without` drop by id. A `match:` glob has no id to drop by, so it survives to the index.
46
+ */
47
+ export declare function reachedEntries(game: GameConfig, profile: ProfileConfig, args: Partial<ParsedArgs>): ModEntry[];
43
48
  /**
44
49
  * The map `prepareSources` builds, read off the cache alone: no lock, no clone, no network.
45
50
  * `mods` and `verify` must not fetch, but with no map at all a git pin's `subdir` is dropped and
@@ -0,0 +1,61 @@
1
+ import type { GameConfig, RootConfig } from '../types';
2
+ export declare const STEAMCMD_IMAGE = "steamcmd/steamcmd";
3
+ /**
4
+ * `host` spawns argv directly with env; `docker` already carries the bind and the user mapping.
5
+ * Either way a caller appends steamcmd's own flags to argv.
6
+ */
7
+ export type SteamcmdRunner = {
8
+ kind: 'host';
9
+ argv: string[];
10
+ env: Record<string, string>;
11
+ } | {
12
+ kind: 'docker';
13
+ argv: string[];
14
+ env: Record<string, string>;
15
+ };
16
+ /** HOME for steamcmd. Everything it writes hangs off here, so it is per-dataRoot, not the user's. */
17
+ export declare function steamHome(dataRoot: string): string;
18
+ /**
19
+ * Where downloads land. `run` pins it with `+force_install_dir`, so this is our layout rather
20
+ * than whichever one the steamcmd on this machine would have picked: measured 2026-09-21, the
21
+ * arch wrapper writes .steam/SteamApps, the docker image writes .local/share/Steam/steamapps,
22
+ * and a plain Valve tarball writes $HOME/Steam. Pinning makes all three land here.
23
+ */
24
+ export declare function downloadRoot(dataRoot: string, game: GameConfig): string;
25
+ /**
26
+ * Drops a game's downloaded items and the .acf beside them. Never the steamcmd install above
27
+ * those: that is 200MB of bootstrap which would re-download for nothing. Returns what it did.
28
+ */
29
+ export declare function removeDownloads(root: string, steamAppId: number): Promise<string>;
30
+ /**
31
+ * Configured path if set, else PATH, else the docker image. A configured path that is not an
32
+ * executable file is an error, not a fallback. Docker gets `--user`: without it the tree
33
+ * comes back root-owned and the next run with a host binary cannot write it. The bind is an
34
+ * identity bind so the paths steamcmd prints are valid on the host too.
35
+ */
36
+ export declare function resolveSteamcmd(config: RootConfig): SteamcmdRunner;
37
+ /** The published file id out of a workshop url, or undefined if it is not one. */
38
+ export declare function workshopUrlId(url: string | undefined): string | undefined;
39
+ export type DownloadOutcome = {
40
+ ok: true;
41
+ dir: string;
42
+ bytes: number;
43
+ } | {
44
+ ok: false;
45
+ reason: string;
46
+ };
47
+ export interface DownloadReport {
48
+ items: Map<string, DownloadOutcome>;
49
+ warnings: string[];
50
+ }
51
+ /**
52
+ * Downloads every id in one steamcmd invocation. Connect is 3.4s of a 3.5s single-item run, so a
53
+ * hundred ids cost about what one does plus the transfer.
54
+ *
55
+ * A failed item is a warning, not a throw: whatever is already on disk stays usable.
56
+ *
57
+ * An anonymous download can fail transiently and succeed untouched on a second pass, so the
58
+ * misses get one re-run before they count as failures. Two passes, not more: a genuinely
59
+ * unavailable item pays the 3.4s connect for every pass that will never succeed.
60
+ */
61
+ export declare function downloadItems(config: RootConfig, game: GameConfig, dataRoot: string, ids: string[]): Promise<DownloadReport>;
@@ -0,0 +1,15 @@
1
+ import type { GamePlugin } from '../plugin';
2
+ import type { GameConfig, ParsedArgs, Problem, RootConfig } from '../types';
3
+ export interface PreparedWorkshop {
4
+ /** Every id this walk inspected: the profile's own, plus the dependencies it reached. */
5
+ ids: Set<string>;
6
+ warnings: string[];
7
+ problems: Problem[];
8
+ /** Inspected ids with no directory under any mounted root when the walk finished. */
9
+ unfetched: string[];
10
+ }
11
+ /**
12
+ * Downloads every workshop item the profile names and every workshop dependency of those,
13
+ * recursively, before the mod index is built. Never throws for a download that failed.
14
+ */
15
+ export declare function prepareWorkshop(game: GameConfig, profileName: string, args: ParsedArgs, config: RootConfig, allowFetch: boolean, plugin: GamePlugin, sources: ReadonlyMap<string, string>): Promise<PreparedWorkshop>;
@@ -0,0 +1,13 @@
1
+ export interface DriftReport {
2
+ /** Ids that need steamcmd, because they drifted, are absent from the .acf, or are not on disk. */
3
+ needed: string[];
4
+ /** Ids steam will not serve: removed, private or hidden. Not the same as up to date. */
5
+ unavailable: string[];
6
+ warnings: string[];
7
+ }
8
+ /**
9
+ * Which of these items changed, without spawning anything. `roots` is every workshop content
10
+ * root, what `downloadRoots` returns, and none of them has to exist. The whole check is best
11
+ * effort: any failure downgrades to the items that are not on disk, never to fetching everything.
12
+ */
13
+ export declare function checkDrift(ids: string[], roots: string[], fetchImpl?: typeof fetch): Promise<DriftReport>;
@@ -145,6 +145,7 @@ export interface ProfileConfig {
145
145
  extends?: string;
146
146
  exclude?: string[];
147
147
  includeBase?: boolean;
148
+ /** Default true. False keeps the mod list literal, dependencies and all. */
148
149
  autoDependencies?: boolean;
149
150
  settings?: Partial<Settings>;
150
151
  instances?: Record<string, InstanceConfig>;
@@ -199,6 +200,10 @@ export interface RootConfig {
199
200
  defaults?: {
200
201
  settings?: Partial<Settings>;
201
202
  };
203
+ /** Where the steamcmd binary is. Root level, not per game, because it names a host tool. */
204
+ steamcmd?: {
205
+ path?: string;
206
+ };
202
207
  games: Record<string, GameConfig>;
203
208
  }
204
209
  export type ModSourceKind = 'local' | 'workshop' | 'official' | 'core';
@@ -385,7 +390,7 @@ export interface ParsedArgs {
385
390
  build?: BuildPolicy;
386
391
  sort?: 'topo' | 'none';
387
392
  /** `clean` only: --staging is the default, --all additionally requires --yes. */
388
- cleanTier?: 'staging' | 'logs' | 'all';
393
+ cleanTier?: 'staging' | 'logs' | 'all' | 'downloads';
389
394
  dockerArgs: string[];
390
395
  gameArgs: string[];
391
396
  dryRun: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamecrate/cli",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Run a modded game in a container, with mods resolved from your local checkouts.",
5
5
  "license": "MIT",
6
6
  "type": "module",