@gamecrate/cli 1.1.0 → 1.2.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
@@ -194,6 +194,37 @@ var profile = obj({
194
194
  });
195
195
  }
196
196
  });
197
+ var libraryEntry = obj({
198
+ workshop: num.optional(),
199
+ path: str.optional(),
200
+ git: str.optional(),
201
+ branch: str.optional(),
202
+ tag: str.optional(),
203
+ commit: str.optional(),
204
+ subdir: str.optional()
205
+ }).check((ctx) => {
206
+ const v = ctx.value;
207
+ const push = (message, path) => {
208
+ ctx.issues.push({ code: "custom", message, input: v, ...path === undefined ? {} : { path } });
209
+ };
210
+ const sources = ["workshop", "path", "git"].filter((k) => v[k] !== undefined);
211
+ if (sources.length === 0)
212
+ push('library entry needs a "workshop" id, a "path", or a "git" url');
213
+ if (sources.length > 1)
214
+ push(`library entry takes only one of ${sources.join(", ")}`);
215
+ const refs = ["branch", "tag", "commit"].filter((k) => v[k] !== undefined);
216
+ if (refs.length > 1)
217
+ push(`library entry takes only one of branch, tag or commit, got ${refs.join(", ")}`);
218
+ if (v["git"] === undefined) {
219
+ for (const key of [...refs, ...v["subdir"] === undefined ? [] : ["subdir"]]) {
220
+ push(`"${key}" needs a "git" url`, [key]);
221
+ }
222
+ }
223
+ const subdir = v["subdir"];
224
+ if (typeof subdir === "string" && (subdir.startsWith("/") || subdir.split("/").includes(".."))) {
225
+ push('"subdir" must be a relative path inside the repo, with no ".." segment', ["subdir"]);
226
+ }
227
+ });
197
228
  var game = obj({
198
229
  gameFiles: obj({ source: oneOf(["mount", "image"]), host: str.optional(), container: str }).check(requiredWhen("host", (v) => v["source"] === "mount")),
199
230
  dataDir: obj({
@@ -219,15 +250,7 @@ var game = obj({
219
250
  dlc: strArray,
220
251
  preCore: strArray.optional(),
221
252
  base: strArray.optional(),
222
- library: z.record(z.string(), obj({ workshop: num.optional(), path: str.optional() }).check((ctx) => {
223
- if (ctx.value["workshop"] === undefined && ctx.value["path"] === undefined) {
224
- ctx.issues.push({
225
- code: "custom",
226
- message: 'library entry needs a "workshop" id or a "path"',
227
- input: ctx.value
228
- });
229
- }
230
- }), { error: "expected an object" }).optional(),
253
+ library: z.record(z.string(), libraryEntry, { error: "expected an object" }).optional(),
231
254
  modes: z.array(modeName, { error: "expected a non-empty array" }).min(1, {
232
255
  error: "expected a non-empty array"
233
256
  }),
@@ -268,6 +291,7 @@ var PROJECT_OBJECT = z2.strictObject({
268
291
  defaultProfile: projectName.optional(),
269
292
  profiles: z2.record(z2.string(), z2.unknown()).optional(),
270
293
  settings: z2.record(z2.string(), z2.unknown()).optional(),
294
+ library: z2.record(z2.string(), z2.unknown()).optional(),
271
295
  detach: projectBool.optional(),
272
296
  mods: projectList.optional(),
273
297
  without: projectList.optional(),
@@ -298,10 +322,14 @@ var PROJECT_OBJECT = z2.strictObject({
298
322
  resolution: projectResolution.optional()
299
323
  }, { error: "expected an object" });
300
324
  var PROJECT_SCHEMA = PROJECT_OBJECT.check((ctx) => {
301
- const { game, profiles, settings } = ctx.value;
325
+ const { game, profiles, settings, library } = ctx.value;
302
326
  if (game !== undefined)
303
327
  return;
304
- for (const [key, value] of [["profiles", profiles], ["settings", settings]]) {
328
+ for (const [key, value] of [
329
+ ["profiles", profiles],
330
+ ["settings", settings],
331
+ ["library", library]
332
+ ]) {
305
333
  if (value === undefined)
306
334
  continue;
307
335
  ctx.issues.push({
@@ -9,6 +9,8 @@ export interface SubcommandSpec {
9
9
  positionals: PositionalSlot[];
10
10
  /** Flag names beyond the global set, in the order help should show them. */
11
11
  flags: readonly string[];
12
+ /** Slots to use instead of `positionals` when the next word is one of these. */
13
+ subverbs?: Readonly<Record<string, PositionalSlot[]>>;
12
14
  }
13
15
  /** The subcommand table. `modless` is reserved as a built-in profile, not a verb. */
14
16
  export declare const SUBCOMMANDS: readonly SubcommandSpec[];
@@ -0,0 +1,16 @@
1
+ import type { GamePlugin } from '../plugin';
2
+ import type { ParsedArgs, ProjectDefaults, RootConfig } from '../types';
3
+ export interface ModsContext {
4
+ config: RootConfig;
5
+ plugins: Map<string, GamePlugin>;
6
+ defaults: ProjectDefaults;
7
+ /** Where findProjectConfig starts walking. Injectable so tests need no chdir. */
8
+ cwd: string;
9
+ /** The resolved global config path, or where to create one. Injectable for the same reason. */
10
+ globalPath: string;
11
+ }
12
+ /** The same fallback loadConfig uses, so an error names the file `config edit` would open. */
13
+ export declare function globalConfigPath(): Promise<string>;
14
+ export declare function modsAdd(args: ParsedArgs, ctx: ModsContext): Promise<number>;
15
+ export declare function modsRm(args: ParsedArgs, ctx: ModsContext): Promise<number>;
16
+ export declare function modsSync(args: ParsedArgs, ctx: ModsContext): Promise<number>;
@@ -0,0 +1,14 @@
1
+ /** A value of `undefined` deletes the key rather than writing an undefined. */
2
+ export interface ConfigEdit {
3
+ path: (string | number)[];
4
+ value: unknown;
5
+ }
6
+ /**
7
+ * jsonc-parser never sniffs the file, so an inserted block lands with whatever width it is
8
+ * handed. A file indented with tabs and edited with spaces reads as two files.
9
+ */
10
+ export declare function detectIndent(text: string): {
11
+ tabSize: number;
12
+ insertSpaces: boolean;
13
+ };
14
+ export declare function writeConfig(file: string, edits: ConfigEdit[]): Promise<void>;
@@ -18,7 +18,7 @@ export declare function runtimeLayerRef(ref: string): string;
18
18
  export declare function ensureRuntimeLayer(ref: string): Promise<string>;
19
19
  /**
20
20
  * Builds local mods before launch. `always` builds every local mod; `auto` builds only the
21
- * ones resolution flagged stale; `never` skips. A build failure stops the launch — shipping
21
+ * ones resolution flagged stale; `never` skips. A build failure stops the launch, shipping
22
22
  * the previous DLL after a failed compile is how you debug code that is not running.
23
23
  */
24
24
  export declare function buildLocalMods(plan: LaunchPlan, policy: BuildPolicy): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import type { GamePlugin } from '../plugin';
2
- import type { LaunchPlan, ModIndex, ParsedArgs, Problem, RootConfig } from '../types';
2
+ import type { GameConfig, LaunchPlan, ModIndex, ParsedArgs, Problem, RootConfig } from '../types';
3
3
  export interface ResolveOptions {
4
4
  game: string;
5
5
  profile: string;
@@ -10,7 +10,14 @@ export interface ResolveOptions {
10
10
  index?: ModIndex;
11
11
  /** Overrides process.cwd() for ambient worktree detection; tests set it. */
12
12
  cwd?: string;
13
+ /** Lowercased packageId to the clone directory prepared for its git pin. */
14
+ sources?: ReadonlyMap<string, string>;
13
15
  }
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;
14
21
  export declare function resolvePlan(options: ResolveOptions): Promise<{
15
22
  plan: LaunchPlan;
16
23
  problems: Problem[];
@@ -18,10 +18,11 @@ export declare function applyWorktreeRequests(index: ModIndex, requests: Worktre
18
18
  */
19
19
  export declare function applySourceOverrides(index: ModIndex, overrides: string[], config: GameConfig): Promise<Problem[]>;
20
20
  /**
21
- * Scans the game install, then every scan root in declaration order, then the workshop root.
22
- * Local roots rescan every launch; only the workshop scan is cached, against the acf stamp.
21
+ * 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.
23
24
  */
24
- export declare function buildIndex(game: string, config: GameConfig, plugin: GamePlugin): Promise<ModIndex>;
25
+ export declare function buildIndex(game: string, config: GameConfig, plugin: GamePlugin, sourcesDir?: string): Promise<ModIndex>;
25
26
  /**
26
27
  * `path:` and `workshop:` are explicit; a bare string resolves as exact packageId, then the
27
28
  * game's alias map, then a CLI-only short name. Ambiguity that the ladder cannot break is fatal.
@@ -0,0 +1,56 @@
1
+ import type { GameConfig, LibraryEntry, ParsedArgs } from '../types';
2
+ export type GitRef = {
3
+ kind: 'branch' | 'tag' | 'commit';
4
+ value: string;
5
+ };
6
+ /**
7
+ * A library pin by id, case-blind. Exact then lowercase covers every normal config without a
8
+ * scan; the scan is the only way to reach a mixed-case key from a ref spelled differently.
9
+ */
10
+ export declare function libraryPin(game: GameConfig, id: string): LibraryEntry | undefined;
11
+ export declare function sourcesRoot(dataRoot: string): string;
12
+ export declare function normalizeUrl(url: string): string;
13
+ export declare function cloneDir(dataRoot: string, url: string, ref: GitRef): string;
14
+ export interface GitPin {
15
+ url: string;
16
+ ref?: GitRef;
17
+ subdir?: string;
18
+ }
19
+ export type SyncMode = 'use' | 'fetch' | 'force';
20
+ export interface SyncResult {
21
+ dir: string;
22
+ warning?: string;
23
+ }
24
+ export declare function isMoving(ref: GitRef): boolean;
25
+ export declare function gitRefOf(pin: {
26
+ branch?: string;
27
+ tag?: string;
28
+ commit?: string;
29
+ }): GitRef | undefined;
30
+ export declare function defaultBranch(url: string): GitRef;
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>>;
33
+ export declare function unlinkOrphan(path: string, seen: string): Promise<boolean>;
34
+ export interface PreparedSources {
35
+ /** lowercased packageId -> clone directory. Handed to resolvePlan as `sources`. */
36
+ dirs: Map<string, string>;
37
+ warnings: string[];
38
+ /** One entry per distinct clone this call handled, fetched or reused. Makes dedupe testable. */
39
+ fetched: string[];
40
+ /** Released by execute(), and by run()'s finally. Never before buildLocalMods. */
41
+ release: () => Promise<void>;
42
+ }
43
+ /**
44
+ * The map `prepareSources` builds, read off the cache alone: no lock, no clone, no network.
45
+ * `mods` and `verify` must not fetch, but with no map at all a git pin's `subdir` is dropped and
46
+ * the scan answers by packageId instead, which is a coin flip between two directories of one
47
+ * repo. An unpinned entry has no ref to derive a directory from, so it takes the one `branch-*`
48
+ * clone already on disk.
49
+ */
50
+ export declare function cachedSources(game: GameConfig, profileName: string, args: Partial<ParsedArgs>, dataRoot: string): Map<string, string>;
51
+ /**
52
+ * Clones or fetches every git-pinned mod the run will ask for, before the index is built, and
53
+ * keeps one lock per clone until the caller releases it. `git()` is spawnSync, so these
54
+ * serialize whatever this function looks like.
55
+ */
56
+ export declare function prepareSources(game: GameConfig, profileName: string, args: ParsedArgs, dataRoot: string, allowFetch: boolean): Promise<PreparedSources>;
@@ -110,6 +110,11 @@ export interface ScanRoot {
110
110
  export interface LibraryEntry {
111
111
  workshop?: number;
112
112
  path?: string;
113
+ git?: string;
114
+ branch?: string;
115
+ tag?: string;
116
+ commit?: string;
117
+ subdir?: string;
113
118
  }
114
119
  /** Matches a family of mods by pattern instead of naming each one. */
115
120
  export interface DynamicModEntry {
@@ -235,6 +240,11 @@ export interface ModRecord {
235
240
  };
236
241
  /** Which scanRoot produced it, for the precedence ladder. */
237
242
  rootIndex: number;
243
+ /**
244
+ * The clone directory's mtime. Source-cache records only, which is what keeps it from
245
+ * reordering a user's checkout against anything.
246
+ */
247
+ clonedAt?: number;
238
248
  }
239
249
  export type WorktreeSource = 'flag' | 'env' | 'cwd' | 'ref';
240
250
  export interface WorktreeRequest {
@@ -405,14 +415,38 @@ export interface ParsedArgs {
405
415
  supervised: boolean;
406
416
  /** `-f`: keep printing as the run writes, instead of dumping what is there. */
407
417
  follow: boolean;
418
+ /** The write verb under `mods`. Unset means the read verb. */
419
+ subverb?: 'add' | 'rm' | 'sync';
420
+ /** Where `mods add` pulls the mod from. */
421
+ source?: {
422
+ kind: 'path';
423
+ value: string;
424
+ } | {
425
+ kind: 'workshop';
426
+ value: number;
427
+ } | {
428
+ kind: 'git';
429
+ url: string;
430
+ ref?: {
431
+ kind: 'branch' | 'tag' | 'commit';
432
+ value: string;
433
+ };
434
+ subdir?: string;
435
+ };
436
+ /** Which config file a write lands in. */
437
+ target?: 'global' | 'project';
438
+ /** Overwrite an existing entry instead of refusing. */
439
+ force?: boolean;
408
440
  rest: string[];
409
441
  }
410
- export type ProjectDefaults = Partial<Omit<ParsedArgs, 'subcommand' | 'cleanTier' | 'yes' | 'help' | 'rest' | 'profile' | 'supervised' | 'noDetach' | 'noReplace' | 'follow'>> & {
442
+ export type ProjectDefaults = Partial<Omit<ParsedArgs, 'subcommand' | 'cleanTier' | 'yes' | 'help' | 'rest' | 'profile' | 'supervised' | 'noDetach' | 'noReplace' | 'follow' | 'subverb' | 'source' | 'target' | 'force'>> & {
411
443
  /** Replaces the old `profile:` key. Falls back to the first entry in `profiles`. */
412
444
  defaultProfile?: string;
413
445
  /** Validated by validateConfig after the splice, not here. */
414
446
  profiles?: Record<string, unknown>;
415
447
  settings?: Record<string, unknown>;
448
+ /** Spliced per id over games.<game>.library, so a repo pin replaces a global one whole. */
449
+ library?: Record<string, unknown>;
416
450
  /** Profile keys in source order. Object.keys sorts integer-like names to the front. */
417
451
  profileOrder?: string[];
418
452
  /** The file these came from. Four suffixes are legal, so output must not guess the name. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamecrate/cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",