@gjsify/cli 0.56.0 → 0.57.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.
@@ -6,6 +6,19 @@ interface FlatpakSourcesOptions {
6
6
  out?: string;
7
7
  cacheRoot?: string;
8
8
  printModule?: boolean;
9
+ focus?: string[];
9
10
  }
11
+ /** One vendored tarball: its download URL + its SRI integrity (`sha512-…`). */
12
+ interface Tarball {
13
+ url: string;
14
+ integrity: string;
15
+ }
16
+ /**
17
+ * `--focus` (ADR 0102): the tarballs the focused workspace closure installs. The same
18
+ * `planFocus` + `reachableInstallPaths` pair `gjsify install --focus` uses, so the
19
+ * Flatpak cache holds exactly what the offline install reads. Workspace-named
20
+ * packages are dropped as the installer drops them (they are symlinks, not fetches).
21
+ */
22
+ export declare function focusedTarballs(lockfile: string, focus: string[]): Tarball[];
10
23
  export declare const flatpakSourcesCommand: Command<unknown, FlatpakSourcesOptions>;
11
24
  export {};
@@ -19,6 +19,9 @@
19
19
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
20
20
  import { dirname, join, resolve } from 'node:path';
21
21
  import { Buffer } from 'node:buffer';
22
+ import { discoverWorkspaces } from '@gjsify/workspace';
23
+ import { planFocus } from '../../utils/install-focus.js';
24
+ import { parseSpecName, readLockfileNodes, reachableInstallPaths } from '../../utils/install-backend-native.js';
22
25
  import { FLATPAK_SOURCES_FILE } from './utils.js';
23
26
  /** Checksum algorithms flatpak-builder accepts on a `file` source. */
24
27
  const FLATPAK_ALGOS = new Set(['sha256', 'sha512']);
@@ -187,6 +190,25 @@ function defaultLockfile(cwd) {
187
190
  }
188
191
  return 'gjsify-lock.json'; // surfaces a clear "not found" later
189
192
  }
193
+ /**
194
+ * `--focus` (ADR 0102): the tarballs the focused workspace closure installs. The same
195
+ * `planFocus` + `reachableInstallPaths` pair `gjsify install --focus` uses, so the
196
+ * Flatpak cache holds exactly what the offline install reads. Workspace-named
197
+ * packages are dropped as the installer drops them (they are symlinks, not fetches).
198
+ */
199
+ export function focusedTarballs(lockfile, focus) {
200
+ const root = dirname(lockfile);
201
+ const workspaces = discoverWorkspaces(root, { includeRoot: true });
202
+ const plan = planFocus(workspaces, focus, 'gjsify flatpak sources');
203
+ const nodes = readLockfileNodes(lockfile);
204
+ if (!nodes)
205
+ throw new Error(`gjsify flatpak sources: ${lockfile} is not a readable gjsify-lock.json.`);
206
+ const reachable = reachableInstallPaths(nodes, plan.specs.map(parseSpecName));
207
+ const workspaceNames = new Set(workspaces.map((w) => w.name));
208
+ return nodes
209
+ .filter((n) => reachable.has(n.installPath) && !workspaceNames.has(n.name))
210
+ .flatMap((n) => (n.tarballUrl && n.integrity ? [{ url: n.tarballUrl, integrity: n.integrity }] : []));
211
+ }
190
212
  function parseLockfile(path, type) {
191
213
  const text = readFileSync(path, 'utf-8');
192
214
  if (type === 'yarn')
@@ -228,6 +250,11 @@ export const flatpakSourcesCommand = {
228
250
  'Tarballs land at <cache-root>/gjsify/tarballs/v1/<algo>/<shard>/<hex>.tgz.',
229
251
  type: 'string',
230
252
  default: 'flatpak-gjsify-cache',
253
+ })
254
+ .option('focus', {
255
+ description: 'Only the tarballs the named workspaces (package names), the workspaces they depend on, and their external dependencies need, as `gjsify install --focus` installs them. gjsify-lock.json only.',
256
+ type: 'string',
257
+ array: true,
231
258
  })
232
259
  .option('print-module', {
233
260
  description: 'Also print a ready-to-paste flatpak manifest module snippet to stderr',
@@ -243,7 +270,12 @@ export const flatpakSourcesCommand = {
243
270
  'Run `gjsify install` (or npm/yarn/pnpm) first, or pass --lockfile.');
244
271
  }
245
272
  const type = args.type ?? detectType(lockfile);
246
- const tarballs = parseLockfile(lockfile, type);
273
+ const focus = args.focus;
274
+ if (focus && focus.length > 0 && type !== 'gjsify') {
275
+ throw new Error(`gjsify flatpak sources: --focus reads the per-package edges of gjsify-lock.json; ` +
276
+ `${type} lockfiles are not supported with it.`);
277
+ }
278
+ const tarballs = focus && focus.length > 0 ? focusedTarballs(lockfile, focus) : parseLockfile(lockfile, type);
247
279
  const cacheRoot = args.cacheRoot ?? 'flatpak-gjsify-cache';
248
280
  // Dedupe by content hash — the same tarball is locked at many install
249
281
  // paths; flatpak rejects duplicate dest/filename pairs.
@@ -9,6 +9,7 @@ interface InstallOptions {
9
9
  immutable?: boolean;
10
10
  prune?: boolean;
11
11
  'refresh-lockfile'?: boolean;
12
+ focus?: string[];
12
13
  verbose: boolean;
13
14
  quiet?: boolean;
14
15
  progress?: boolean;
@@ -31,6 +31,7 @@ import { acquireInstallLock } from '../utils/install-lock.js';
31
31
  import { nodeBinary } from '../utils/run-node.js';
32
32
  import { spawnToCompletion } from '../utils/spawn.js';
33
33
  import { findWorkspaceRoot } from '../utils/workspace-root.js';
34
+ import { planFocus } from '../utils/install-focus.js';
34
35
  import { binDirOnPath, BUNDLER_ENGINE_PACKAGE, defaultGlobalLayout, hasBundlerEngineInstalled, installGjsEnginePackages, linkGlobalBins, specToPackageName, } from '../utils/install-global.js';
35
36
  import { resolveHostPlatform } from '../utils/platform-check.js';
36
37
  import { pruneAfterInstall } from '../utils/prune-prefix.js';
@@ -86,6 +87,11 @@ export const installCommand = {
86
87
  description: 'Re-resolve every dependency to the newest version satisfying its range and rewrite the lockfile (bumps in-range transitive deps). Without it, a resolve preserves versions already pinned in the lockfile and only resolves new/changed deps — the npm/yarn/pnpm default. Mirrors yarn install --mode=update-lockfile.',
87
88
  type: 'boolean',
88
89
  default: false,
90
+ })
91
+ .option('focus', {
92
+ description: 'Install only the named workspaces (package names), the workspaces they depend on, and their external dependencies, as if they were the only members of the monorepo (yarn workspaces focus). Reads gjsify-lock.json and never rewrites it; fails if it is missing or stale. Workspace projects only.',
93
+ type: 'string',
94
+ array: true,
89
95
  })
90
96
  .option('verbose', {
91
97
  description: 'Verbose install logging.',
@@ -665,6 +671,10 @@ async function workspaceInstallLocked(cwd, args, signal) {
665
671
  throw new Error(`gjsify install: ${cwd} has a "workspaces" field but no workspaces were discovered`);
666
672
  }
667
673
  const byName = new Map(workspaces.map((w) => [w.name, w]));
674
+ // `--focus`: the members that are installed. The lockfile and its drift check
675
+ // still cover EVERY member (ADR 0102 § 2), so `externalSpecs` below stays the full set.
676
+ const focusPlan = args.focus && args.focus.length > 0 ? planFocus(workspaces, args.focus, 'gjsify install') : undefined;
677
+ const active = focusPlan ? new Set(focusPlan.workspaces) : undefined;
668
678
  const externalSpecs = new Set();
669
679
  // `"<name>@<range>"` → the members that declared it. Feeds the resolver's
670
680
  // version-conflict warning so it can attribute both sides of a conflict.
@@ -691,6 +701,8 @@ async function workspaceInstallLocked(cwd, args, signal) {
691
701
  // Only an EXPLICIT out-of-workspace protocol opts out.
692
702
  const explicitOverride = /^(link|file|portal|git\+|https?):/.test(spec);
693
703
  if (!explicitOverride) {
704
+ if (active && !active.has(ws))
705
+ continue;
694
706
  symlinks.push({
695
707
  fromWorkspaceName: ws.name,
696
708
  depName,
@@ -729,7 +741,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
729
741
  // the workspace without its runner shim (`node_modules/.bin/gjsify`), and a
730
742
  // stale shim is refreshed even when a later phase aborts. Rewritten at the end
731
743
  // of this function so the GJS preamble reflects the installed tree.
732
- writeWorkspaceBinShims(cwd, workspaces);
744
+ writeWorkspaceBinShims(cwd, focusPlan?.workspaces ?? workspaces);
733
745
  // Per-requester links (`<requester>/node_modules/<dep>`) plus a root hoist of
734
746
  // every workspace into `node_modules/<name>`, so transitive workspace deps
735
747
  // resolve by Node's parent-walk from any descendant. Idempotent, hence callable
@@ -793,7 +805,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
793
805
  // runtime with `Module not found`.
794
806
  const rootBinDir = join(cwd, 'node_modules');
795
807
  let rootHoisted = 0;
796
- for (const ws of workspaces) {
808
+ for (const ws of focusPlan?.workspaces ?? workspaces) {
797
809
  // The root workspace cannot symlink itself into its own node_modules.
798
810
  if (ws.location === cwd)
799
811
  continue;
@@ -843,7 +855,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
843
855
  const wsLocalSpecs = new Map(); // wsLocation → name@range set
844
856
  const droppedFromExternal = new Set();
845
857
  if (extracted && extracted.scoped.size > 0) {
846
- for (const ws of workspaces) {
858
+ for (const ws of focusPlan?.workspaces ?? workspaces) {
847
859
  const wsManifest = ws.manifest;
848
860
  for (const kind of ['dependencies', 'devDependencies', 'optionalDependencies']) {
849
861
  const deps = wsManifest[kind];
@@ -914,6 +926,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
914
926
  // Aggregated over EVERY member, because the specs are: a name any member
915
927
  // declares as a plain dependency stays required.
916
928
  optionalSpecs: optionalDependencyNames(workspaces.map((w) => w.manifest)),
929
+ focusSpecs: focusPlan?.specs,
917
930
  };
918
931
  await installPackages(rootOpts);
919
932
  }
@@ -955,7 +968,7 @@ async function workspaceInstallLocked(cwd, args, signal) {
955
968
  // fresh checkout `detectNativePackages` finds nothing before extraction).
956
969
  // Running last also keeps workspace shims authoritative over any same-named
957
970
  // external bin the backend linked — yarn semantics: workspace bins win.
958
- const wsBinsCreated = writeWorkspaceBinShims(cwd, workspaces);
971
+ const wsBinsCreated = writeWorkspaceBinShims(cwd, focusPlan?.workspaces ?? workspaces);
959
972
  if (wsBinsCreated > 0) {
960
973
  console.log(`gjsify install: linked ${wsBinsCreated} workspace bin(s) into node_modules/.bin/`);
961
974
  }
@@ -108,6 +108,17 @@ export interface NativeInstallOptions extends InstallOptions {
108
108
  * been written from the full resolved tree.
109
109
  */
110
110
  linkedNames?: Set<string>;
111
+ /**
112
+ * `--focus` (ADR 0102): the external `"<name>@<range>"` specs of the focused
113
+ * workspace closure. Only the packages reachable from these are fetched and
114
+ * extracted. `specs` stays the FULL set, because the drift check compares it
115
+ * against the lockfile's `requested` and the lockfile stays complete.
116
+ *
117
+ * Implies a lockfile-authoritative install: a missing or stale lockfile throws
118
+ * instead of resolving (a resolve would reach the toolchains the focus excludes),
119
+ * and nothing is ever written back, so the file cannot become a subset.
120
+ */
121
+ focusSpecs?: string[];
111
122
  }
112
123
  export declare function installPackagesNative(opts: NativeInstallOptions): Promise<InstalledTopLevel[]>;
113
124
  /**
@@ -144,6 +155,7 @@ export declare function applyPlatformFilter(nodes: ResolvedNode[], target: Platf
144
155
  * Pure detection — no script is run, so node-free-ness is preserved.
145
156
  */
146
157
  export declare function warnMissingNativeBuilds(nodes: ResolvedNode[], prefix: string, log: Logger): void;
158
+ export declare function parseSpecName(spec: string): string;
147
159
  /**
148
160
  * A node's REQUIRED dependency edges: its `dependencies` minus every name its
149
161
  * `optionalDependencies` also lists. The ONE place that decides an edge's kind —
@@ -209,6 +221,21 @@ export declare function requiredPeerEntries(node: Pick<ResolvedNode, 'dependenci
209
221
  * composition, worth pinning without a registry.
210
222
  */
211
223
  export declare function computeOptionalFlags(nodes: ResolvedNode[], requiredNames: Set<string>, log: Logger): void;
224
+ /**
225
+ * The `installPath`s reachable from the top-level packages named in `seedNames`, through
226
+ * `dependencies`, `optionalDependencies` and REQUIRED peers — what `--focus` (ADR 0102)
227
+ * keeps. Optional edges are followed on purpose: whether one installs is a per-host
228
+ * verdict (`applyPlatformFilter`), and a closure that left them out would depend on the
229
+ * host that computed it. An optional peer is not an edge, as the resolver never places it.
230
+ *
231
+ * Same walk as {@link computeOptionalFlags} — seeds are exact because a top-level spec
232
+ * always takes the root slot, and an edge resolves through {@link findVisible} the way
233
+ * the requester will at runtime. A seed with no placement is a workspace member (its
234
+ * symlink is wired elsewhere) or a name absent from the lockfile; both are skipped.
235
+ */
236
+ export declare function reachableInstallPaths(nodes: readonly ResolvedNode[], seedNames: Iterable<string>): Set<string>;
237
+ /** The nodes the lockfile at `lockfilePath` describes, or null when it is missing or unreadable. */
238
+ export declare function readLockfileNodes(lockfilePath: string): ResolvedNode[] | null;
212
239
  export declare function parseSpec(raw: string): ParsedSpec;
213
240
  export declare function pickVersion(packument: Packument, range: string, preferred?: string): string | null;
214
241
  /**
@@ -157,20 +157,23 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
157
157
  * resolve nothing.
158
158
  */
159
159
  let skippedEdges = new Set();
160
- if (opts.frozen) {
160
+ if (opts.frozen || opts.focusSpecs) {
161
161
  // --immutable / --frozen: the lockfile is authoritative. Reject a missing,
162
162
  // version-mismatched or drifted file — silently honouring a stale lockfile
163
- // masks the real dep churn `--immutable` exists to catch.
163
+ // masks the real dep churn `--immutable` exists to catch. `--focus` is the
164
+ // same promise without the tree check: it reads the lockfile, never resolves.
165
+ const flag = opts.frozen ? '--immutable' : '--focus';
166
+ const retry = opts.frozen ? '(without --immutable)' : '(without --focus)';
164
167
  if (!existingLock) {
165
- throw new Error(`install: --immutable requires ${LOCKFILE_NAME} at ${opts.prefix} — none found. ` +
166
- `Run \`gjsify install\` (without --immutable) to generate one and commit it.`);
168
+ throw new Error(`install: ${flag} requires ${LOCKFILE_NAME} at ${opts.prefix} — none found. ` +
169
+ `Run \`gjsify install\` ${retry} to generate one and commit it.`);
167
170
  }
168
171
  const drift = describeLockfileDrift(existingLock, opts.specs);
169
172
  if (drift) {
170
- throw new Error(`install: --immutable but ${lockfilePath} is stale.\n${drift}\n` +
171
- `Re-run \`gjsify install\` (without --immutable) to refresh the lockfile.`);
173
+ throw new Error(`install: ${flag} but ${lockfilePath} is stale.\n${drift}\n` +
174
+ `Re-run \`gjsify install\` ${retry} to refresh the lockfile.`);
172
175
  }
173
- log('install: --immutable, using lockfile (%d package(s))', Object.keys(existingLock.packages).length);
176
+ log('install: %s, using lockfile (%d package(s))', flag, Object.keys(existingLock.packages).length);
174
177
  nodes = lockfileToNodes(existingLock);
175
178
  // ...and the tree must hold NOTHING ELSE. The installer only adds, so without
176
179
  // this an undescribed package survives the install and exits 0 — the CI cache
@@ -178,8 +181,17 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
178
181
  // Before the download so a wrong tree costs seconds, not the extract phase,
179
182
  // and with the FULL lockfile set: the workspace filter below removes nodes
180
183
  // that ARE described, and judging against the smaller set would call them
181
- // strangers.
182
- assertNoExtraneous(opts.prefix, nodes);
184
+ // strangers. `--focus` alone skips it: a tree an earlier full install left is
185
+ // not extraneous, and nothing here promises the tree matches the lockfile.
186
+ if (opts.frozen)
187
+ assertNoExtraneous(opts.prefix, nodes);
188
+ // The focus narrows AFTER the extraneous check, which judges the full set, and
189
+ // before the optional-flag fixpoint, which must see only what will be installed.
190
+ if (opts.focusSpecs) {
191
+ const reachable = reachableInstallPaths(nodes, opts.focusSpecs.map(parseSpecName));
192
+ log('install: --focus keeps %d of %d lockfile package(s)', reachable.size, nodes.length);
193
+ nodes = nodes.filter((n) => reachable.has(n.installPath));
194
+ }
183
195
  }
184
196
  else if (!opts.refreshLockfile &&
185
197
  existingLock &&
@@ -228,7 +240,7 @@ async function installPackagesNativeLocked(opts, npmrc, log) {
228
240
  // before `writeLockfile` (so the persisted flag is final), before the platform
229
241
  // verdict (which reads it for fatal vs. inert), and before the workspace filter
230
242
  // below (which removes nodes and would truncate the walk).
231
- computeOptionalFlags(nodes, requiredTopLevelNames(opts.specs, opts.optionalSpecs), log);
243
+ computeOptionalFlags(nodes, requiredTopLevelNames(opts.focusSpecs ?? opts.specs, opts.optionalSpecs), log);
232
244
  assertRequiredEdgesResolved(nodes, skippedEdges);
233
245
  if (resolved && opts.lockfile) {
234
246
  // The FULL resolved set, inert nodes included — see the header note:
@@ -440,7 +452,7 @@ function topLevelResolutions(specs, nodes) {
440
452
  }
441
453
  return out;
442
454
  }
443
- function parseSpecName(spec) {
455
+ export function parseSpecName(spec) {
444
456
  if (spec.startsWith('@')) {
445
457
  const slash = spec.indexOf('/');
446
458
  if (slash === -1)
@@ -957,6 +969,48 @@ export function computeOptionalFlags(nodes, requiredNames, log) {
957
969
  }
958
970
  log('install: optionality fixpoint — %d of %d node(s) reachable only via optional edges%s', nodes.length - required.size, nodes.length, corrected > 0 ? `; corrected ${corrected} incoming flag(s)` : '');
959
971
  }
972
+ /**
973
+ * The `installPath`s reachable from the top-level packages named in `seedNames`, through
974
+ * `dependencies`, `optionalDependencies` and REQUIRED peers — what `--focus` (ADR 0102)
975
+ * keeps. Optional edges are followed on purpose: whether one installs is a per-host
976
+ * verdict (`applyPlatformFilter`), and a closure that left them out would depend on the
977
+ * host that computed it. An optional peer is not an edge, as the resolver never places it.
978
+ *
979
+ * Same walk as {@link computeOptionalFlags} — seeds are exact because a top-level spec
980
+ * always takes the root slot, and an edge resolves through {@link findVisible} the way
981
+ * the requester will at runtime. A seed with no placement is a workspace member (its
982
+ * symlink is wired elsewhere) or a name absent from the lockfile; both are skipped.
983
+ */
984
+ export function reachableInstallPaths(nodes, seedNames) {
985
+ const byPath = new Map();
986
+ for (const node of nodes)
987
+ byPath.set(node.installPath, node);
988
+ const reached = new Set();
989
+ const worklist = [];
990
+ const enter = (node) => {
991
+ if (!node || reached.has(node.installPath))
992
+ return;
993
+ reached.add(node.installPath);
994
+ worklist.push(node);
995
+ };
996
+ for (const name of seedNames)
997
+ enter(byPath.get(`node_modules/${name}`));
998
+ for (let node = worklist.pop(); node; node = worklist.pop()) {
999
+ const edges = [
1000
+ ...Object.keys(node.dependencies),
1001
+ ...Object.keys(node.optionalDependencies),
1002
+ ...requiredPeerEntries(node).map(([name]) => name),
1003
+ ];
1004
+ for (const name of edges)
1005
+ enter(findVisible(node.installPath, name, byPath));
1006
+ }
1007
+ return reached;
1008
+ }
1009
+ /** The nodes the lockfile at `lockfilePath` describes, or null when it is missing or unreadable. */
1010
+ export function readLockfileNodes(lockfilePath) {
1011
+ const lock = readLockfile(lockfilePath);
1012
+ return lock ? lockfileToNodes(lock) : null;
1013
+ }
960
1014
  /**
961
1015
  * Re-judge every failure the resolve swallowed against the FIXPOINT flags, and throw
962
1016
  * if one turned out to be required after all.
@@ -0,0 +1,41 @@
1
+ import type { Workspace } from '@gjsify/workspace';
2
+ /** The three blocks `gjsify install` reads from a member; same list as install.ts. */
3
+ export declare const DEPENDENCY_BLOCKS: readonly ["dependencies", "devDependencies", "optionalDependencies"];
4
+ export type DependencyClass = {
5
+ kind: 'workspace';
6
+ target: Workspace;
7
+ }
8
+ /** `workspace:` against a name that is not a member: a typo or a missing package. */
9
+ | {
10
+ kind: 'missing-workspace';
11
+ }
12
+ /** `link:`/`file:`/`portal:`/`git+`/`http(s):` — neither linked nor fetched. */
13
+ | {
14
+ kind: 'ignored';
15
+ } | {
16
+ kind: 'external';
17
+ };
18
+ /**
19
+ * What a `name: spec` entry of a member's manifest is. A name that matches a member is
20
+ * the member whatever the spec form (`workspace:`, `^1.2.3`, a dist-tag) — only an
21
+ * explicit out-of-workspace protocol opts out — because routing it to the registry would
22
+ * unpack a published tarball over the member's own source tree.
23
+ */
24
+ export declare function classifyDependency(depName: string, spec: string, byName: ReadonlyMap<string, Workspace>): DependencyClass;
25
+ export interface FocusPlan {
26
+ /** The closure, in discovery order: the named workspaces and everything they depend on. */
27
+ workspaces: Workspace[];
28
+ /** `"<name>@<range>"` of every external dependency the closure's manifests declare. */
29
+ specs: string[];
30
+ }
31
+ /**
32
+ * Resolve `--focus` names to the workspace closure and its external specs.
33
+ *
34
+ * Throws naming every unknown workspace — a typo must not become an install of
35
+ * nothing. Names are exact package names, the spelling the rest of the CLI uses; the
36
+ * root is a member like any other (Yarn's `workspaces focus` rule) and is in the
37
+ * closure only when named or depended on. A root without a `name` cannot be named.
38
+ *
39
+ * @param command label for error messages, e.g. `gjsify install`.
40
+ */
41
+ export declare function planFocus(workspaces: readonly Workspace[], focus: readonly string[], command: string): FocusPlan;
@@ -0,0 +1,80 @@
1
+ // `--focus <workspace…>` for `gjsify install` and `gjsify flatpak sources`
2
+ // (ADR 0102): act on the named workspaces, the workspaces they depend on, and the
3
+ // external packages those need — as if they were the only members of the monorepo.
4
+ //
5
+ // THE MANIFEST HALF lives here; the LOCKFILE HALF (`reachableInstallPaths`) lives in
6
+ // `install-backend-native.ts` beside `computeOptionalFlags`, because it reads the same
7
+ // graph through the same private helpers. `gjsify-lock.json` records package → package
8
+ // edges but no workspace → package edges, so the seeds of the walk come from the
9
+ // manifests (on disk wherever either command runs) and the lockfile only continues it.
10
+ //
11
+ // ONE classifier decides what a dependency IS, and `workspaceInstallLocked` uses the
12
+ // same one for the full install. The closure and the install cannot disagree about
13
+ // whether `@scope/foo: ^1.0.0` is a workspace edge or a registry package, and that
14
+ // agreement is what lets `flatpak sources --focus` fill exactly the cache the focused
15
+ // install later reads. `@gjsify/workspace`'s `buildDependencyGraph` is deliberately not
16
+ // used: it decides by "does the member's version satisfy the range", this installer by
17
+ // NAME (a name-matched member is always symlinked — install.ts, `localWorkspace`).
18
+ /** The three blocks `gjsify install` reads from a member; same list as install.ts. */
19
+ export const DEPENDENCY_BLOCKS = ['dependencies', 'devDependencies', 'optionalDependencies'];
20
+ /** Protocols that opt a dependency out of both the workspace link and the registry. */
21
+ const EXPLICIT_PROTOCOL = /^(link|file|portal|git\+|https?):/;
22
+ /**
23
+ * What a `name: spec` entry of a member's manifest is. A name that matches a member is
24
+ * the member whatever the spec form (`workspace:`, `^1.2.3`, a dist-tag) — only an
25
+ * explicit out-of-workspace protocol opts out — because routing it to the registry would
26
+ * unpack a published tarball over the member's own source tree.
27
+ */
28
+ export function classifyDependency(depName, spec, byName) {
29
+ const target = byName.get(depName);
30
+ if (target && !EXPLICIT_PROTOCOL.test(spec))
31
+ return { kind: 'workspace', target };
32
+ if (spec.startsWith('workspace:'))
33
+ return { kind: 'missing-workspace' };
34
+ if (EXPLICIT_PROTOCOL.test(spec))
35
+ return { kind: 'ignored' };
36
+ return { kind: 'external' };
37
+ }
38
+ /** At most this many known names are listed in the unknown-name error. */
39
+ const KNOWN_NAMES_SHOWN = 20;
40
+ /**
41
+ * Resolve `--focus` names to the workspace closure and its external specs.
42
+ *
43
+ * Throws naming every unknown workspace — a typo must not become an install of
44
+ * nothing. Names are exact package names, the spelling the rest of the CLI uses; the
45
+ * root is a member like any other (Yarn's `workspaces focus` rule) and is in the
46
+ * closure only when named or depended on. A root without a `name` cannot be named.
47
+ *
48
+ * @param command label for error messages, e.g. `gjsify install`.
49
+ */
50
+ export function planFocus(workspaces, focus, command) {
51
+ const byName = new Map(workspaces.map((w) => [w.name, w]));
52
+ const unknown = [...new Set(focus)].filter((name) => !byName.has(name));
53
+ if (unknown.length > 0) {
54
+ const known = [...byName.keys()].sort();
55
+ const shown = known.slice(0, KNOWN_NAMES_SHOWN).join(', ');
56
+ const more = known.length > KNOWN_NAMES_SHOWN ? `, … (${known.length - KNOWN_NAMES_SHOWN} more)` : '';
57
+ throw new Error(`${command}: --focus names unknown workspace(s): ${unknown.join(', ')}. ` +
58
+ `Pass a package name from a workspace's package.json. Known workspaces: ${shown}${more}.`);
59
+ }
60
+ // A Set grows while iterated, so each pass visits what the previous one added.
61
+ const closure = new Set(focus.map((name) => byName.get(name)));
62
+ const specs = new Set();
63
+ for (const ws of closure) {
64
+ for (const block of DEPENDENCY_BLOCKS) {
65
+ for (const [depName, spec] of Object.entries(ws.manifest[block] ?? {})) {
66
+ if (typeof spec !== 'string')
67
+ continue;
68
+ const cls = classifyDependency(depName, spec, byName);
69
+ if (cls.kind === 'workspace')
70
+ closure.add(cls.target);
71
+ else if (cls.kind === 'external')
72
+ specs.add(`${depName}@${spec}`);
73
+ }
74
+ }
75
+ }
76
+ return {
77
+ workspaces: workspaces.filter((w) => closure.has(w)),
78
+ specs: [...specs],
79
+ };
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -142,22 +142,22 @@
142
142
  "cli"
143
143
  ],
144
144
  "dependencies": {
145
- "@gjsify/blueprint": "^0.56.0",
146
- "@gjsify/buffer": "^0.56.0",
147
- "@gjsify/create-app": "^0.56.0",
148
- "@gjsify/node-globals": "^0.56.0",
149
- "@gjsify/node-polyfills": "^0.56.0",
150
- "@gjsify/npm-registry": "^0.56.0",
151
- "@gjsify/resolve-npm": "^0.56.0",
152
- "@gjsify/rolldown-plugin-gjsify": "^0.56.0",
153
- "@gjsify/vite-plugin-blueprint": "^0.56.0",
154
- "@gjsify/rolldown-plugin-pnp": "^0.56.0",
155
- "@gjsify/semver": "^0.56.0",
156
- "@gjsify/tar": "^0.56.0",
157
- "@gjsify/tsc": "^0.56.0",
158
- "@gjsify/utils": "^0.56.0",
159
- "@gjsify/web-polyfills": "^0.56.0",
160
- "@gjsify/workspace": "^0.56.0",
145
+ "@gjsify/blueprint": "^0.57.0",
146
+ "@gjsify/buffer": "^0.57.0",
147
+ "@gjsify/create-app": "^0.57.0",
148
+ "@gjsify/node-globals": "^0.57.0",
149
+ "@gjsify/node-polyfills": "^0.57.0",
150
+ "@gjsify/npm-registry": "^0.57.0",
151
+ "@gjsify/resolve-npm": "^0.57.0",
152
+ "@gjsify/rolldown-plugin-gjsify": "^0.57.0",
153
+ "@gjsify/vite-plugin-blueprint": "^0.57.0",
154
+ "@gjsify/rolldown-plugin-pnp": "^0.57.0",
155
+ "@gjsify/semver": "^0.57.0",
156
+ "@gjsify/tar": "^0.57.0",
157
+ "@gjsify/tsc": "^0.57.0",
158
+ "@gjsify/utils": "^0.57.0",
159
+ "@gjsify/web-polyfills": "^0.57.0",
160
+ "@gjsify/workspace": "^0.57.0",
161
161
  "acorn": "^8.17.0",
162
162
  "cosmiconfig": "^9.0.2",
163
163
  "get-tsconfig": "^4.14.0",
@@ -166,13 +166,13 @@
166
166
  "yargs": "^18.0.0"
167
167
  },
168
168
  "devDependencies": {
169
- "@gjsify/unit": "^0.56.0",
169
+ "@gjsify/unit": "^0.57.0",
170
170
  "@types/yargs": "^17.0.35",
171
171
  "typescript": "^6.0.3"
172
172
  },
173
173
  "peerDependencies": {
174
- "@gjsify/oxfmt-native": "^0.56.0",
175
- "@gjsify/rolldown-native": "^0.56.0"
174
+ "@gjsify/oxfmt-native": "^0.57.0",
175
+ "@gjsify/rolldown-native": "^0.57.0"
176
176
  },
177
177
  "peerDependenciesMeta": {
178
178
  "@gjsify/oxfmt-native": {