@gjsify/cli 0.64.12 → 0.64.13

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.
@@ -1,5 +1,5 @@
1
1
  import { runBundle, runWatch, bundleToChunks } from '../bundler-pick.js';
2
- import { gjsifyPlugin, textLoaderPlugin, resolveShebangLine, NODE_SHEBANG, createGjsExternalsPredicate, globToEntryPoints, } from '@gjsify/rolldown-plugin-gjsify';
2
+ import { gjsifyPlugin, textLoaderPlugin, resolveShebangLine, NODE_SHEBANG, createGjsExternalsPredicate, globToEntryPoints, scanGiNamespaces, planGiSelection, } from '@gjsify/rolldown-plugin-gjsify';
3
3
  import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
4
4
  import { resolveUserPlugins } from '../utils/resolve-plugin-by-name.js';
5
5
  import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals, detectNodeGiGlobals, } from '@gjsify/rolldown-plugin-gjsify/globals';
@@ -15,8 +15,6 @@ import { collectEntryPaths, describeJsxConfig, findJsxEntryPoint, jsxConfigMissi
15
15
  import { giSystemProbes } from '../utils/gi-runtime-paths.js';
16
16
  import { escapeRawNulForGjs } from '../utils/gjs-source-escape.js';
17
17
  import { assertNodeBundleGlobalsShimmed } from '../utils/node-bundle-guard.js';
18
- import { scanGiNamespaces } from '../utils/ship/gi-namespaces.js';
19
- import { planGiSelection } from '../utils/ship/gi-selection.js';
20
18
  import { DEP_CHANGE_LOCKFILES } from '../utils/package-inputs.js';
21
19
  const DEFAULT_GJS_SHEBANG = '#!/usr/bin/env -S gjs -m';
22
20
  /**
@@ -700,7 +698,7 @@ export class BuildAction {
700
698
  throw new Error('gjsify build: an .html entry needs an explicit --outfile ' +
701
699
  '(e.g. --outfile dist-app/app.js); the emitted index.html is written beside it.');
702
700
  }
703
- const { consoleShim, globals, dialect, giRenderer } = this.configData;
701
+ const { consoleShim, globals, dialect, giRenderer, nativescriptLocale } = this.configData;
704
702
  const userExternal = Array.isArray(userBundler.external) ? userBundler.external : undefined;
705
703
  const userBanner = typeof userBundler.output?.banner === 'string' ? userBundler.output.banner : undefined;
706
704
  const pluginOpts = {
@@ -712,6 +710,7 @@ export class BuildAction {
712
710
  consoleShim,
713
711
  ...(dialect !== undefined ? { dialect } : {}),
714
712
  ...(giRenderer === true ? { giRenderer: true } : {}),
713
+ ...(nativescriptLocale ? { nativescriptLocale } : {}),
715
714
  ...(aliases ? { aliases } : {}),
716
715
  ...(opts.preserveDefaultExport ? { preserveDefaultExport: true } : {}),
717
716
  ...(opts.toolchainAnchor !== undefined ? { toolchainAnchor: opts.toolchainAnchor } : {}),
@@ -12,7 +12,7 @@
12
12
  // step is an install anyway.
13
13
  import { existsSync } from 'node:fs';
14
14
  import { relative, resolve } from 'node:path';
15
- import { applyDevLinks, consumerKnownNames, DEV_LINK_FILE, devLinkPath, ensureLocallyIgnored, formatUnbuiltDevLinks, planDevLinks, prepareDevLinks, readDevLinkOverride, removeDevLinkOverride, removeDevLinks, removeLocalIgnore, resolveCheckoutWorkspaces, retireDevLinks, scanDevLinks, unbuiltDevLinks, writeDevLinkOverride, } from '../utils/dev-link.js';
15
+ import { applyDevLinks, consumerKnownNames, consumerNestedPlacements, DEV_LINK_FILE, devLinkPath, ensureLocallyIgnored, formatUnbuiltDevLinks, planDevLinks, prepareDevLinks, readDevLinkOverride, removeDevLinkOverride, removeDevLinks, removeLocalIgnore, resolveCheckoutWorkspaces, retireDevLinks, scanDevLinks, unbuiltDevLinks, writeDevLinkOverride, } from '../utils/dev-link.js';
16
16
  export const linkCommand = {
17
17
  command: 'link <checkout>',
18
18
  description: 'Link this project against a local gjsify checkout for development: its workspace packages replace the installed registry copies, recorded in a git-ignored .gjsify-link.json that every later `gjsify install` re-applies. Undo with `gjsify unlink`.',
@@ -23,7 +23,7 @@ export const linkCommand = {
23
23
  demandOption: true,
24
24
  })
25
25
  .option('packages', {
26
- description: "Name glob selecting which of the checkout's workspace packages take part (repeatable; default: all of them). Matched against the package NAME with the same single-segment dialect as `gjsify foreach --include`, so a whole scope is `@gjsify/*` — a bare `*` matches no scoped name. Only packages this project actually depends on are ever linked.",
26
+ description: "Name glob selecting which of the checkout's workspace packages take part (repeatable; default: all of them). Matched against the package NAME with the same single-segment dialect as `gjsify foreach --include`, so a whole scope is `@gjsify/*` — a bare `*` matches no scoped name. Only packages this project actually uses are linked: declared dependencies plus everything in its lockfile or installed node_modules, transitive and nested copies included.",
27
27
  type: 'string',
28
28
  array: true,
29
29
  })
@@ -50,10 +50,11 @@ export const linkCommand = {
50
50
  workspaces,
51
51
  patterns,
52
52
  knownNames: consumerKnownNames(consumerRoot),
53
+ nested: consumerNestedPlacements(consumerRoot),
53
54
  });
54
55
  if (links.length === 0) {
55
56
  console.error(`gjsify link: nothing to link — ${checkout} has ${workspaces.length} workspace package(s), ` +
56
- `none of which ${consumerRoot} depends on${patterns.length === 0 ? '' : ` and matches ${patterns.join(', ')}`}.\n` +
57
+ `none of which ${consumerRoot} uses (declared, locked or installed)${patterns.length === 0 ? '' : ` and matches ${patterns.join(', ')}`}.\n` +
57
58
  `A link that links nothing is a silent no-op, so this is an error. Check the --packages ` +
58
59
  `pattern, or run \`gjsify install\` first so the dependency tree exists.`);
59
60
  return process.exit(1);
@@ -52,7 +52,7 @@ import { compileSchemasForPayload, compileSchemasForStage } from '../utils/ship/
52
52
  import { buildMsi, MSI_PAYLOAD_DIR } from '../utils/ship/msi.js';
53
53
  import { buildZip, zipEntriesFromPayload } from '../utils/ship/zip.js';
54
54
  import { assertEntryRunsUnder } from '../utils/ship/entry-interpreter.js';
55
- import { scanGiRequirements } from '../utils/ship/gi-namespaces.js';
55
+ import { scanGiRequirements } from '@gjsify/rolldown-plugin-gjsify';
56
56
  import { assertLauncherMatchesInterpreter, assertPayloadMatchesArch, buildTimestamp, isArchIndependent, linuxInstallDependent, readPayloadFacts, } from '../utils/ship/payload.js';
57
57
  import { assertOverlayIsLicensed, planOverlay, planStage } from '../utils/ship/plan.js';
58
58
  import { recordedSignature, writeReleaseRecord } from '../utils/ship/release-record.js';
package/lib/config.js CHANGED
@@ -5,7 +5,7 @@ import { basename, dirname } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { isGjs, hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
7
7
  import { buildAppForRuntime } from './utils/runtimes.js';
8
- import { DIALECT_APPS, isSourceDialect, SOURCE_DIALECTS } from '@gjsify/rolldown-plugin-gjsify';
8
+ import { assertGiNamespaceEntries, DIALECT_APPS, isSourceDialect, SOURCE_DIALECTS, } from '@gjsify/rolldown-plugin-gjsify';
9
9
  import { GI_RENDERER_APPS } from '@gjsify/resolve-npm';
10
10
  /**
11
11
  * Does the failed `import()` of a config look like a MODULE-RESOLUTION failure
@@ -326,16 +326,10 @@ export class Config {
326
326
  'It selects what an Android APK carries (ADR 0117); every other target resolves ' +
327
327
  '`gi://` against a GI stack the host already has.');
328
328
  }
329
- for (const entry of configData.nativescript?.giNamespaces ?? []) {
330
- // `Ns` or `Ns-Version`, because that is what the AAR builder resolves
331
- // against the typelib names in the sysroot. A free-form string here
332
- // surfaces as "the prefix has no typelib for …" hours later, naming the
333
- // typo rather than the shape.
334
- if (!/^[A-Za-z][A-Za-z\d_]*(-\d+(\.\d+)*)?$/.test(entry)) {
335
- throw new Error(`gjsify build: nativescript.giNamespaces entry "${entry}" is not a GI namespace. ` +
336
- 'Write it as `Gtk` or `Gtk-4.0`.');
337
- }
338
- }
329
+ // Shape-checked through the shared helper, because the Vite path accepts the
330
+ // same key and a check that holds on only one of them is a project that builds
331
+ // one way and fails the other for a typo.
332
+ assertGiNamespaceEntries(configData.nativescript?.giNamespaces ?? [], 'gjsify build: nativescript.giNamespaces');
339
333
  if (cliArgs.globals !== undefined)
340
334
  configData.globals = cliArgs.globals;
341
335
  // Fallback applied post-merge (not as a yargs `default:`) so a
@@ -110,6 +110,16 @@ export interface ConfigData {
110
110
  * into a hard build failure.
111
111
  */
112
112
  giRenderer?: boolean;
113
+ /**
114
+ * `--app nativescript`: compile `poDirectory/<lang>.po` into
115
+ * `locale/<lang>/LC_MESSAGES/<domain>.mo` assets of the app, so the Android GLib gettext
116
+ * finds them under `<filesDir>/app/locale`. Config-file only (an object has no flag).
117
+ */
118
+ nativescriptLocale?: {
119
+ poDirectory: string;
120
+ domain: string;
121
+ localeNames?: Record<string, string[]>;
122
+ };
113
123
  /** Comma-separated global identifiers to register. Format: see CliBuildOptions. */
114
124
  globals?: string;
115
125
  /**
@@ -1,66 +1,4 @@
1
1
  import type { Workspace } from '@gjsify/workspace';
2
- /**
3
- * Literal module specifiers of `code`: static `import`/`export … from`, plus
4
- * `import('<literal>')`.
5
- *
6
- * acorn rather than a regex — see the header. `allowHashBang` because the CLI's
7
- * own entry is a bin (`#!/usr/bin/env node`), and a parse failure yields NO
8
- * specifiers, which under-selects rather than mis-selects.
9
- */
10
- export interface SpecifierNode {
11
- type?: string;
12
- value?: unknown;
13
- expressions?: unknown[];
14
- quasis?: Array<{
15
- value?: {
16
- cooked?: unknown;
17
- };
18
- }>;
19
- }
20
- /**
21
- * The string a node names statically, or `null` when it is computed.
22
- *
23
- * A substitution-free TEMPLATE LITERAL is as static as a quoted string, and
24
- * skipping it is not hypothetical: the minifier rewrites `await
25
- * import('gi://GLib?version=2.0')` to a backtick form, so a Literal-only reader
26
- * silently loses every dynamic import in a built bundle.
27
- *
28
- * Not named for specifiers, because it does not only read them: the same
29
- * question is asked of a CALL ARGUMENT by `utils/ship/gi-namespaces.ts`, whose
30
- * `requireGi("Gtk", "4.0")` carries a namespace and a version in exactly the two
31
- * spellings above.
32
- */
33
- export declare function staticStringValue(node: SpecifierNode | undefined): string | null;
34
- /** One node of the walk: whatever acorn produced, seen as a bag of properties. */
35
- export type AstNode = {
36
- type?: string;
37
- } & Record<string, unknown>;
38
- /**
39
- * Parse `code` as a module and call `visit` for every node in it.
40
- *
41
- * Exported because a SECOND parse is how the two readers of a built bundle
42
- * would drift apart: {@link moduleSpecifiers} answers "what does this file
43
- * import" and `utils/ship/gi-namespaces.ts` answers "which GI namespaces does
44
- * it load", and the second question has an answer the first cannot see — a
45
- * `--app node` bundle carries no `gi://` specifier at all, only the
46
- * `requireGi()` calls the bundler rewrote them into. One walker, two readers.
47
- *
48
- * A parse failure yields NO nodes rather than throwing: every caller is
49
- * summarising a file it did not write, and under-selecting is the failure mode
50
- * that keeps a correct project buildable.
51
- */
52
- export declare function walkModuleAst(code: string, visit: (node: AstNode) => void): void;
53
- /**
54
- * The module `node` imports from, or `null` when `node` is not an import at all.
55
- *
56
- * FOUR node types, in one place. Every reader of a built bundle in this tree asks
57
- * the same question of the same four — a static `import`, both `export … from`
58
- * forms and `import()` — and each copy of that list is a chance for one reader to
59
- * miss a form the others catch. The bare side-effect `import "gi://Soup"` is what
60
- * that costs when it happens (`utils/ship/gi-namespaces.ts`).
61
- */
62
- export declare function importedSpecifier(node: AstNode): string | null;
63
- export declare function moduleSpecifiers(code: string): string[];
64
2
  export interface CliRuntimeClosureOptions {
65
3
  /**
66
4
  * File to start the walk from. Defaults to the running CLI's Node entry
@@ -51,9 +51,9 @@
51
51
  // optional PEER dependencies. So the peers of every package in the graph are
52
52
  // added: one declaration closes both blind spots, and it stays a declaration the
53
53
  // package owns rather than a list this file keeps.
54
- import * as acorn from 'acorn';
55
54
  import { existsSync, readFileSync, statSync } from 'node:fs';
56
55
  import { dirname, join, resolve as resolvePath } from 'node:path';
56
+ import { moduleSpecifiers } from '@gjsify/rolldown-plugin-gjsify';
57
57
  import { cliPackageDir } from './publish-headers.js';
58
58
  /**
59
59
  * Split a bare specifier into a WORKSPACE package name + its `exports` key, or
@@ -78,95 +78,6 @@ function splitWorkspaceSpecifier(specifier, byName) {
78
78
  }
79
79
  return null;
80
80
  }
81
- /**
82
- * The string a node names statically, or `null` when it is computed.
83
- *
84
- * A substitution-free TEMPLATE LITERAL is as static as a quoted string, and
85
- * skipping it is not hypothetical: the minifier rewrites `await
86
- * import('gi://GLib?version=2.0')` to a backtick form, so a Literal-only reader
87
- * silently loses every dynamic import in a built bundle.
88
- *
89
- * Not named for specifiers, because it does not only read them: the same
90
- * question is asked of a CALL ARGUMENT by `utils/ship/gi-namespaces.ts`, whose
91
- * `requireGi("Gtk", "4.0")` carries a namespace and a version in exactly the two
92
- * spellings above.
93
- */
94
- export function staticStringValue(node) {
95
- if (node?.type === 'Literal')
96
- return typeof node.value === 'string' ? node.value : null;
97
- if (node?.type === 'TemplateLiteral' && node.expressions?.length === 0) {
98
- const cooked = node.quasis?.[0]?.value?.cooked;
99
- return typeof cooked === 'string' ? cooked : null;
100
- }
101
- return null;
102
- }
103
- /**
104
- * Parse `code` as a module and call `visit` for every node in it.
105
- *
106
- * Exported because a SECOND parse is how the two readers of a built bundle
107
- * would drift apart: {@link moduleSpecifiers} answers "what does this file
108
- * import" and `utils/ship/gi-namespaces.ts` answers "which GI namespaces does
109
- * it load", and the second question has an answer the first cannot see — a
110
- * `--app node` bundle carries no `gi://` specifier at all, only the
111
- * `requireGi()` calls the bundler rewrote them into. One walker, two readers.
112
- *
113
- * A parse failure yields NO nodes rather than throwing: every caller is
114
- * summarising a file it did not write, and under-selecting is the failure mode
115
- * that keeps a correct project buildable.
116
- */
117
- export function walkModuleAst(code, visit) {
118
- let ast;
119
- try {
120
- ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module', allowHashBang: true });
121
- }
122
- catch {
123
- return;
124
- }
125
- const descend = (node) => {
126
- if (!node || typeof node !== 'object')
127
- return;
128
- if (Array.isArray(node)) {
129
- for (const child of node)
130
- descend(child);
131
- return;
132
- }
133
- const n = node;
134
- visit(n);
135
- for (const [key, value] of Object.entries(n)) {
136
- if (key === 'type' || key === 'start' || key === 'end' || key === 'loc')
137
- continue;
138
- descend(value);
139
- }
140
- };
141
- descend(ast);
142
- }
143
- /**
144
- * The module `node` imports from, or `null` when `node` is not an import at all.
145
- *
146
- * FOUR node types, in one place. Every reader of a built bundle in this tree asks
147
- * the same question of the same four — a static `import`, both `export … from`
148
- * forms and `import()` — and each copy of that list is a chance for one reader to
149
- * miss a form the others catch. The bare side-effect `import "gi://Soup"` is what
150
- * that costs when it happens (`utils/ship/gi-namespaces.ts`).
151
- */
152
- export function importedSpecifier(node) {
153
- if (node.type !== 'ImportDeclaration' &&
154
- node.type !== 'ExportNamedDeclaration' &&
155
- node.type !== 'ExportAllDeclaration' &&
156
- node.type !== 'ImportExpression') {
157
- return null;
158
- }
159
- return staticStringValue(node.source);
160
- }
161
- export function moduleSpecifiers(code) {
162
- const out = [];
163
- walkModuleAst(code, (node) => {
164
- const specifier = importedSpecifier(node);
165
- if (specifier !== null)
166
- out.push(specifier);
167
- });
168
- return out;
169
- }
170
81
  /** The file a relative specifier names, probing the extensionless spellings Node accepts. */
171
82
  function resolveRelative(fromFile, specifier) {
172
83
  const base = resolvePath(dirname(fromFile), specifier);
@@ -83,6 +83,22 @@ export declare function resolveCheckoutWorkspaces(checkout: string, overridePath
83
83
  * loads — are covered too).
84
84
  */
85
85
  export declare function consumerKnownNames(consumerRoot: string): Set<string>;
86
+ /** A package copy that sits below another package's `node_modules`, not at the consumer's top level. */
87
+ export interface NestedPlacement {
88
+ name: string;
89
+ /** Absolute path of the copy. */
90
+ dir: string;
91
+ }
92
+ /**
93
+ * Real (non-symlink) copies installed below `<consumer>/node_modules/<pkg>/node_modules`.
94
+ *
95
+ * Node resolves the nearest copy, so a top-level link alone leaves a nested
96
+ * registry copy in charge for everything under its parent — the same silent
97
+ * "testing the published copy" this feature exists to prevent. Only what is on
98
+ * disk counts: the installer excludes linked names from its fetch set at every
99
+ * depth, so a nested copy that is not there yet will not appear.
100
+ */
101
+ export declare function consumerNestedPlacements(consumerRoot: string): NestedPlacement[];
86
102
  /**
87
103
  * The links an override asks for: the checkout's workspace packages, narrowed by
88
104
  * the name globs, INTERSECTED with what the consumer actually depends on.
@@ -98,6 +114,8 @@ export declare function planDevLinks(opts: {
98
114
  workspaces: readonly Workspace[];
99
115
  patterns: readonly string[];
100
116
  knownNames: ReadonlySet<string>;
117
+ /** Installed copies below another package's `node_modules`; see {@link consumerNestedPlacements}. */
118
+ nested?: readonly NestedPlacement[];
101
119
  }): DevLink[];
102
120
  /**
103
121
  * Read + validate the override at `consumerRoot` and plan its links, or `null`
@@ -136,9 +154,9 @@ export declare function removeDevLinks(links: readonly DevLink[]): DevLink[];
136
154
  * it removed the override (the escape route) before hitting the same abort. Only a
137
155
  * hand-written `rm` got the project back.
138
156
  *
139
- * So the ground truth for "what is linked" is the tree, not the file. Reads one
140
- * level of `node_modules` plus one level into each `@scope/`, which is where
141
- * top-level links live; `.bin` and real directories are skipped by construction.
157
+ * So the ground truth for "what is linked" is the tree, not the file. Walks every
158
+ * `node_modules` level (top-level and nested copies) without following links;
159
+ * `.bin` and real directories are skipped by construction.
142
160
  */
143
161
  export declare function scanDevLinks(consumerRoot: string, checkouts: readonly string[]): DevLink[];
144
162
  /**
@@ -35,8 +35,8 @@
35
35
  // committed lockfile the moment a link is active, which is the one thing this
36
36
  // feature promises not to do. `linkedNames` therefore applies after the lockfile
37
37
  // is written and only to what gets fetched.
38
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs';
39
- import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
38
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs';
39
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
40
40
  import { discoverWorkspaces, filterWorkspaces } from '@gjsify/workspace';
41
41
  import { linkDirSync } from './dir-link.js';
42
42
  import { readPackageJson } from './pkg-json.js';
@@ -179,8 +179,51 @@ export function consumerKnownNames(consumerRoot) {
179
179
  }
180
180
  for (const pkg of scanPrefix(consumerRoot))
181
181
  names.add(pkg.name);
182
+ // The lockfile answers for a consumer that has not installed yet (or was
183
+ // cleaned): its keys are the whole resolved closure, transitive packages
184
+ // included, so a link made before the first install covers them too.
185
+ for (const name of lockfileNames(consumerRoot))
186
+ names.add(name);
182
187
  return names;
183
188
  }
189
+ /** Package names behind the `node_modules/…` keys of `gjsify-lock.json`; empty when unreadable. */
190
+ function lockfileNames(consumerRoot) {
191
+ try {
192
+ const lock = JSON.parse(readFileSync(join(consumerRoot, 'gjsify-lock.json'), 'utf-8'));
193
+ const names = [];
194
+ for (const key of Object.keys(lock.packages ?? {})) {
195
+ const at = key.lastIndexOf('node_modules/');
196
+ if (at !== -1)
197
+ names.push(key.slice(at + 'node_modules/'.length));
198
+ }
199
+ return names;
200
+ }
201
+ catch {
202
+ return [];
203
+ }
204
+ }
205
+ /**
206
+ * Real (non-symlink) copies installed below `<consumer>/node_modules/<pkg>/node_modules`.
207
+ *
208
+ * Node resolves the nearest copy, so a top-level link alone leaves a nested
209
+ * registry copy in charge for everything under its parent — the same silent
210
+ * "testing the published copy" this feature exists to prevent. Only what is on
211
+ * disk counts: the installer excludes linked names from its fetch set at every
212
+ * depth, so a nested copy that is not there yet will not appear.
213
+ */
214
+ export function consumerNestedPlacements(consumerRoot) {
215
+ const top = join(consumerRoot, 'node_modules') + sep;
216
+ const nested = [];
217
+ for (const pkg of scanPrefix(consumerRoot)) {
218
+ if (pkg.linked)
219
+ continue;
220
+ const rel = pkg.dir.startsWith(top) ? pkg.dir.slice(top.length) : '';
221
+ if (!rel.includes(`${sep}node_modules${sep}`))
222
+ continue;
223
+ nested.push({ name: pkg.name, dir: pkg.dir });
224
+ }
225
+ return nested;
226
+ }
184
227
  /**
185
228
  * The links an override asks for: the checkout's workspace packages, narrowed by
186
229
  * the name globs, INTERSECTED with what the consumer actually depends on.
@@ -209,7 +252,19 @@ export function planDevLinks(opts) {
209
252
  linkPath: join(opts.consumerRoot, 'node_modules', ws.name),
210
253
  });
211
254
  }
212
- links.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
255
+ // A nested copy under a package that is itself being linked disappears with
256
+ // that package's directory and resolves into the checkout's own tree instead.
257
+ const topLevel = links.map((l) => l.linkPath + sep);
258
+ const byName = new Map(links.map((l) => [l.name, l]));
259
+ for (const copy of opts.nested ?? []) {
260
+ const link = byName.get(copy.name);
261
+ if (!link)
262
+ continue;
263
+ if (topLevel.some((prefix) => copy.dir.startsWith(prefix)))
264
+ continue;
265
+ links.push({ name: copy.name, target: link.target, linkPath: copy.dir });
266
+ }
267
+ links.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : a.linkPath < b.linkPath ? -1 : a.linkPath > b.linkPath ? 1 : 0);
213
268
  return links;
214
269
  }
215
270
  /**
@@ -228,6 +283,7 @@ export function prepareDevLinks(consumerRoot) {
228
283
  workspaces,
229
284
  patterns: override.packages,
230
285
  knownNames: consumerKnownNames(consumerRoot),
286
+ nested: consumerNestedPlacements(consumerRoot),
231
287
  });
232
288
  return {
233
289
  overridePath,
@@ -325,9 +381,9 @@ function linkAlreadyPointsAt(linkPath, target) {
325
381
  * it removed the override (the escape route) before hitting the same abort. Only a
326
382
  * hand-written `rm` got the project back.
327
383
  *
328
- * So the ground truth for "what is linked" is the tree, not the file. Reads one
329
- * level of `node_modules` plus one level into each `@scope/`, which is where
330
- * top-level links live; `.bin` and real directories are skipped by construction.
384
+ * So the ground truth for "what is linked" is the tree, not the file. Walks every
385
+ * `node_modules` level (top-level and nested copies) without following links;
386
+ * `.bin` and real directories are skipped by construction.
331
387
  */
332
388
  export function scanDevLinks(consumerRoot, checkouts) {
333
389
  const roots = [];
@@ -343,50 +399,31 @@ export function scanDevLinks(consumerRoot, checkouts) {
343
399
  }
344
400
  if (roots.length === 0)
345
401
  return [];
346
- const nodeModules = join(consumerRoot, 'node_modules');
402
+ // Every depth: `assertNodeModulesDest` aborts on a link out of `node_modules`
403
+ // wherever it sits, and `scanPrefix` never descends into a link itself.
347
404
  const found = [];
348
- const consider = (name) => {
349
- const linkPath = join(nodeModules, name);
405
+ for (const pkg of scanPrefix(consumerRoot)) {
406
+ if (!pkg.linked)
407
+ continue;
350
408
  let target;
351
409
  try {
352
- if (!lstatSync(linkPath).isSymbolicLink())
353
- return;
354
- target = realpathSync(linkPath);
410
+ target = realpathSync(pkg.dir);
355
411
  }
356
412
  catch {
357
- return;
413
+ continue;
358
414
  }
359
415
  if (!roots.some((root) => target === root || target.startsWith(root + sep)))
360
- return;
361
- found.push({ name, target, linkPath });
362
- };
363
- let entries;
364
- try {
365
- entries = readdirSync(nodeModules);
366
- }
367
- catch {
368
- return [];
369
- }
370
- for (const entry of entries) {
371
- if (entry.startsWith('.'))
372
416
  continue;
373
- if (entry.startsWith('@')) {
374
- let scoped;
375
- try {
376
- scoped = readdirSync(join(nodeModules, entry));
377
- }
378
- catch {
379
- continue;
380
- }
381
- for (const inner of scoped)
382
- consider(`${entry}/${inner}`);
383
- continue;
384
- }
385
- consider(entry);
417
+ found.push({ name: packageNameAt(consumerRoot, pkg.dir), target, linkPath: pkg.dir });
386
418
  }
387
- found.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
419
+ found.sort((a, b) => (a.linkPath < b.linkPath ? -1 : a.linkPath > b.linkPath ? 1 : 0));
388
420
  return found;
389
421
  }
422
+ /** `@scope/name` of the last `node_modules/` segment in `dir`. */
423
+ function packageNameAt(consumerRoot, dir) {
424
+ const rel = relative(consumerRoot, dir).split(sep).join('/');
425
+ return rel.slice(rel.lastIndexOf('node_modules/') + 'node_modules/'.length);
426
+ }
390
427
  /**
391
428
  * Remove every dev link into `checkouts` that `keep` does not name.
392
429
  *
@@ -45,7 +45,7 @@
45
45
  // WHY A REFUSAL AND NOT A WARNING. There is no reading of this artifact under
46
46
  // which it works. A warning at the end of a `ship` run competes with the
47
47
  // artifact path printed beside it, and the artifact is what gets uploaded.
48
- import { importedSpecifier, walkModuleAst } from '../cli-runtime-closure.js';
48
+ import { importedSpecifier, walkModuleAst } from '@gjsify/rolldown-plugin-gjsify';
49
49
  /**
50
50
  * The GJS built-in modules, which are bare specifiers rather than a scheme.
51
51
  *
@@ -66,6 +66,31 @@ export interface SignerDescriptor {
66
66
  /** What a signature from this signer does NOT yet cover — printed once per run. */
67
67
  gap: string;
68
68
  }
69
+ /**
70
+ * An explicit `--identifier` for an image whose FILE NAME is not ASCII, or
71
+ * `undefined` when codesign's own default is already right.
72
+ *
73
+ * WHY THIS EXISTS, measured on macOS 2026-10-30 (steuererklaerung CI run
74
+ * 41050980433, plus mini bundles beside it): the darwin layout puts the
75
+ * interpreter at `Contents/Helpers/<display name>`, so an app called
76
+ * "Steuererklärung" carries a non-ASCII leaf. codesign derives an image's code
77
+ * identifier from that file name; with a NAMED identity the designated
78
+ * requirement becomes `identifier "<derived>" and certificate leaf …`, and
79
+ * `codesign --verify --strict <App>.app` then fails with *"the sealed resource
80
+ * directory is invalid"* for the NFD-derived identifier (`…61cc88…`); why
81
+ * codesign rejects it is not established, only that it does. Ad-hoc (a cdhash
82
+ * requirement, no identifier at all) and ASCII names are unaffected; the same
83
+ * bundle with `--identifier eu.test.x.helper` verifies, and an NFC identifier
84
+ * still does not, so the value has to be plain ASCII rather than merely
85
+ * normalised.
86
+ *
87
+ * Hence: an ASCII leaf keeps codesign's default (this returns `undefined` and
88
+ * the flag is not sent, so every name that already worked is unchanged), and
89
+ * only a non-ASCII leaf gets a stable ASCII identifier. NFD first, so a
90
+ * precomposed "ä" and a decomposed "a"+U+0308 fold to the same value instead of
91
+ * to two identifiers that differ by bytes nobody can see.
92
+ */
93
+ export declare function asciiCodesignIdentifier(fileName: string): string | undefined;
69
94
  /**
70
95
  * The signers, keyed by the LAYOUT they sign — never by the host they run on.
71
96
  *
@@ -156,6 +181,17 @@ export interface SignArgs {
156
181
  entitlements?: string;
157
182
  /** Is {@link identity} the reserved ad-hoc `-`? */
158
183
  adhoc: boolean;
184
+ /**
185
+ * Is {@link file} the bundle DIRECTORY being sealed, rather than one image
186
+ * inside it?
187
+ *
188
+ * A signer needs the distinction because the two invocations are not the
189
+ * same operation on the same kind of object: a bundle takes its identifier
190
+ * from `Info.plist` and must never be given one, while an image's identifier
191
+ * is derived from its own file name — which is where a non-ASCII helper name
192
+ * becomes a problem ({@link asciiCodesignIdentifier}).
193
+ */
194
+ seal: boolean;
159
195
  }
160
196
  /** `codesign --sign -`: the one identity value that names no identity at all. */
161
197
  export declare const ADHOC_IDENTITY = "-";