@runeditrun/rer 0.1.0 → 0.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.
Files changed (109) hide show
  1. package/README.md +115 -2
  2. package/cli/README.md +28 -3
  3. package/cli/contract.mjs +4 -4
  4. package/cli/dev-identity.mjs +2 -1
  5. package/cli/door-mode.mjs +134 -0
  6. package/cli/door.mjs +263 -0
  7. package/cli/hosted-output.mjs +15 -5
  8. package/cli/hydration/browser.mjs +91 -0
  9. package/cli/hydration/cases.mjs +106 -0
  10. package/cli/hydration/check.mjs +105 -0
  11. package/cli/hydration/probe.mjs +164 -0
  12. package/cli/hydration/scripts.mjs +39 -0
  13. package/cli/hydration/verdict.mjs +85 -0
  14. package/cli/inspect.mjs +56 -9
  15. package/cli/lifecycle.mjs +36 -12
  16. package/cli/local.mjs +9 -3
  17. package/cli/main.mjs +2 -0
  18. package/cli/managed-server.mjs +52 -18
  19. package/cli/package-calls.mjs +81 -0
  20. package/cli/package-mode.mjs +46 -0
  21. package/cli/parse.mjs +14 -4
  22. package/cli/pnpm-runtime.mjs +96 -0
  23. package/cli/portability-worker.mjs +2 -2
  24. package/cli/runtime-front-door.mjs +4 -2
  25. package/cli/server-proxy.mjs +69 -5
  26. package/cli/server-registry.mjs +19 -11
  27. package/cli/settings.mjs +14 -0
  28. package/cli/site-check.mjs +96 -0
  29. package/cli/site-preview.mjs +53 -0
  30. package/cli/site-render.mjs +77 -0
  31. package/cli/site.mjs +109 -0
  32. package/global/CHANGELOG.md +63 -0
  33. package/global/README.md +115 -2
  34. package/global/checkout.mjs +24 -10
  35. package/global/cli/install.mjs +6 -2
  36. package/global/delegate.mjs +9 -7
  37. package/global/door/command.mjs +254 -0
  38. package/global/door/control.mjs +65 -0
  39. package/global/door/cookies.mjs +41 -0
  40. package/global/door/pages.mjs +49 -0
  41. package/global/door/policy.mjs +49 -0
  42. package/global/door/routes.mjs +45 -0
  43. package/global/door/server.mjs +255 -0
  44. package/global/door/sites.mjs +192 -0
  45. package/global/launch/dev.mjs +25 -23
  46. package/global/launch/door.mjs +36 -0
  47. package/global/launch/facts.mjs +3 -2
  48. package/global/launch/install-pinned.mjs +22 -0
  49. package/global/launch/prepare.mjs +41 -6
  50. package/global/launch/render.mjs +6 -1
  51. package/global/launch/state.mjs +10 -1
  52. package/global/launch/steps.mjs +24 -10
  53. package/global/launch/stop.mjs +89 -0
  54. package/global/launch/worker.mjs +30 -11
  55. package/global/list.mjs +6 -3
  56. package/global/main.mjs +37 -3
  57. package/global/version.mjs +8 -8
  58. package/modules/dependencies/protocol.mjs +2 -1
  59. package/modules/deploy-kit/README.md +34 -1
  60. package/modules/deploy-kit/artifact.mjs +10 -0
  61. package/modules/deploy-kit/owner.mjs +5 -4
  62. package/modules/deploy-kit/prepare.mjs +3 -2
  63. package/modules/deploy-kit/provider.mjs +6 -3
  64. package/modules/deploy-kit/site.mjs +135 -0
  65. package/modules/erasure/CONTRACT.md +6 -3
  66. package/modules/erasure/README.md +8 -1
  67. package/modules/erasure/audit.mjs +2 -2
  68. package/modules/erasure/cascade.mjs +52 -11
  69. package/modules/erasure/order.mjs +29 -22
  70. package/modules/erasure/plan.mjs +4 -2
  71. package/modules/erasure/queue.mjs +41 -0
  72. package/modules/erasure/scope.mjs +3 -3
  73. package/modules/erasure/verification.mjs +4 -2
  74. package/modules/mail/README.md +1 -1
  75. package/modules/mail/index.mjs +1 -1
  76. package/modules/mail/src/core/errors.mjs +9 -0
  77. package/modules/mail/src/development/index.d.mts +6 -0
  78. package/modules/mail/src/development/index.mjs +6 -3
  79. package/modules/mail/src/development/viewer-html.mjs +3 -3
  80. package/modules/mail/src/development/viewer.mjs +8 -1
  81. package/modules/operator-auth/dev/index.mjs +15 -5
  82. package/modules/operator-auth/http.mjs +2 -3
  83. package/modules/operator-auth/loopback.mjs +17 -0
  84. package/modules/owner-claim/verify.mjs +2 -1
  85. package/modules/portability/README.md +8 -0
  86. package/modules/portability/hash.mjs +6 -2
  87. package/modules/portability/import.mjs +1 -1
  88. package/modules/portability/legacy-files.mjs +4 -4
  89. package/modules/portability/legacy-safety.mjs +51 -10
  90. package/modules/portability/migration-history.mjs +15 -0
  91. package/modules/release/README.md +5 -3
  92. package/modules/release/produce.mjs +5 -2
  93. package/modules/release/source.mjs +15 -4
  94. package/onboarding/adapter-auth.mjs +4 -3
  95. package/onboarding/adapter-settings.mjs +21 -5
  96. package/onboarding/adapter.mjs +2 -1
  97. package/onboarding/app-fetch.mjs +79 -0
  98. package/onboarding/archive-output.d.mts +4 -2
  99. package/onboarding/archive-output.mjs +22 -10
  100. package/onboarding/dependency-approval.mjs +4 -3
  101. package/onboarding/local/environment.mjs +7 -2
  102. package/onboarding/operator-install.mjs +8 -2
  103. package/onboarding/operator-prerequisites.mjs +5 -2
  104. package/onboarding/queued-entry.mjs +9 -5
  105. package/operations/operations.mjs +11 -2
  106. package/package.json +75 -1
  107. package/scripts/publish-check/github_refs.py +28 -2
  108. package/scripts/publish-check/public_hygiene.py +35 -4
  109. package/scripts/publish-check/publication-config.json +14 -1
@@ -4,6 +4,7 @@ import { shared } from '../../cli/shared.mjs';
4
4
  import { execFileSync, spawn } from 'node:child_process';
5
5
  import { realpathSync, existsSync, lstatSync, mkdirSync } from 'node:fs';
6
6
  import { resolve, dirname, relative, isAbsolute } from 'node:path';
7
+ import { stripVTControlCharacters } from 'node:util';
7
8
  import { testSummary } from './test-summary.mjs';
8
9
 
9
10
  export function git(repo, ...args) {
@@ -56,16 +57,26 @@ export function newOutput(repo, output) {
56
57
  return output;
57
58
  }
58
59
 
59
- // Keep child diagnostics private: arbitrary verify scripts can print credential
60
- // values. Only exit status and numeric test summaries leave this boundary.
60
+ // Child output is redacted by the command store before it is kept. A failing
61
+ // step's receipt carries the last 40 lines of it, at most 8 KB; a passing step
62
+ // carries only its exit status and numeric test summaries.
63
+ const TAIL_LINES = 40, TAIL_BYTES = 8192;
64
+ function failureTail(logs) {
65
+ const lines = stripVTControlCharacters(logs.join('\n')).split('\n');
66
+ while (lines.length && lines.at(-1).trim() === '') lines.pop();
67
+ const bytes = Buffer.from(lines.slice(-TAIL_LINES).join('\n'));
68
+ return bytes.subarray(Math.max(0, bytes.length - TAIL_BYTES)).toString('utf8').replace(/^\uFFFD+/, '');
69
+ }
70
+
61
71
  export async function command(command, args, cwd) {
62
72
  const {createCommands}=await shared('commands.mjs');
63
73
  const directory=await mkdtemp(resolve(tmpdir(),'rer-release-'));
64
- const store=await createCommands(directory,{logs:[]},{terminal:()=>{},queue:{verb:'release'}});
74
+ const state={logs:[]};
75
+ const store=await createCommands(directory,state,{terminal:()=>{},queue:{verb:'release'}});
65
76
  const summary=testSummary(),env={...process.env,FORCE_COLOR:'0'};delete env.NODE_TEST_CONTEXT;
66
77
  try {
67
78
  await store.run('Release verification',command,args,{cwd,env,onChild:child=>child.stdout.on('data',bytes=>summary.write(bytes))});
68
79
  return {status:'PASS',exitCode:0,...summary.finish()};
69
- }catch(error){return {status:'FAIL',exitCode:error.childExitCode??null,...summary.finish()};}
80
+ }catch(error){return {status:'FAIL',exitCode:error.childExitCode??null,...summary.finish(),output:failureTail(state.logs)};}
70
81
  finally{await store.closeCommands();await rm(directory,{recursive:true,force:true});}
71
82
  }
@@ -1,4 +1,5 @@
1
1
  import { approveSetupDependencies } from './dependency-approval.mjs';
2
+ import { appFetch } from './app-fetch.mjs';
2
3
  import { createHmac } from 'node:crypto';
3
4
  import {commandFailure,installationFailure} from './failures.mjs';
4
5
  import { labels } from './adapter-labels.mjs';
@@ -10,10 +11,10 @@ const outboxPath = '/__dev/outbox'; // modules/mail/src/development default path
10
11
  // that is not in local mode ignores the header. The proof comes from the
11
12
  // setting the seed's adapter declares as its session secret (sessionSecretKey,
12
13
  // default SESSION_SECRET).
13
- const localCliProof = secret => createHmac('sha256', secret).update('local-cli\0sign-in').digest('hex');
14
+ export const localCliProof = secret => createHmac('sha256', secret).update('local-cli\0sign-in').digest('hex');
14
15
  const post = (context, path, body) => {
15
16
  const secret = context.vars[context.sessionSecretKey ?? 'SESSION_SECRET'];
16
- return fetch(new URL(path, context.origin), {
17
+ return appFetch(new URL(path, context.origin), {
17
18
  method: 'POST', redirect: 'manual', signal: context.signal,
18
19
  headers: { Origin: context.origin, 'Content-Type': 'application/json', ...(typeof secret === 'string' && secret ? { 'x-rer-local-cli': localCliProof(secret) } : {}) }, body: JSON.stringify(body),
19
20
  });
@@ -75,7 +76,7 @@ export async function signIn(context, hook) {
75
76
  async function outboxMessages(context, path, address, required = false) {
76
77
  let response;
77
78
  // A dev-identity seed gates its outbox with the checkout's key; present it.
78
- try { response = await fetch(new URL(path, context.origin), { signal: context.signal, headers: context.devKey ? { 'x-rer-dev-key': context.devKey } : {} }); } catch (error) {
79
+ try { response = await appFetch(new URL(path, context.origin), { signal: context.signal, headers: context.devKey ? { 'x-rer-dev-key': context.devKey } : {} }); } catch (error) {
79
80
  if (context.signal?.aborted) throw error;
80
81
  if(required){context.store.report?.('Local development outbox is unavailable. Run rer setup before requesting sign-in.');throw context.fail('setup');}
81
82
  return [];
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { createServer } from 'node:net';
3
- import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import { labels } from './adapter-labels.mjs';
5
6
  import { credentialValues } from './redaction.mjs';
6
7
  import { privateDirectory, readPrivateFile, writePrivateFile } from './private-files.mjs';
@@ -8,6 +9,9 @@ import { localVariables } from './local/environment.mjs';
8
9
  import { assertRegular, inspect } from './local/files.mjs';
9
10
  import { localOperation } from './local/operation.mjs';
10
11
  import { recoverInterrupted } from './local/recovery.mjs';
12
+ // The shared loopback predicate, beside this copy in Base or in a seed's scripts/.
13
+ const portable = basename(dirname(fileURLToPath(import.meta.url))) === 'base-onboarding';
14
+ const { isLocalhostName } = await import(new URL(portable ? '../base-cli/modules/operator-auth/loopback.mjs' : '../modules/operator-auth/loopback.mjs', import.meta.url));
11
15
 
12
16
  async function freePort() {
13
17
  const socket = createServer();
@@ -17,15 +21,16 @@ async function freePort() {
17
21
  return port;
18
22
  }
19
23
 
20
- // A rerun keeps the loopback origin already saved in the seed's settings, so
21
- // sessions, links and the owner's bookmarks stay valid.
24
+ // A rerun keeps the loopback origin already saved in the seed's settings (a
25
+ // 127.0.0.1 port or a front-door `NAME.localhost` origin), so sessions, links
26
+ // and the owner's bookmarks stay valid.
22
27
  async function chooseOrigin(context, hooks) {
23
28
  if (context.selectedOrigin) return context.selectedOrigin;
24
29
  if (typeof hooks.origin === 'function') return hooks.origin(context);
25
30
  const key = hooks.secrets?.originKey;
26
31
  if (key) {
27
32
  const saved = (await readSettings(context.root, hooks.secrets)).values[key];
28
- try { const url = new URL(saved); if (url.protocol === 'http:' && url.hostname === '127.0.0.1' && url.port && url.origin === saved) return saved; } catch {}
33
+ try { const url = new URL(saved); if (url.protocol === 'http:' && (url.hostname === '127.0.0.1' || isLocalhostName(url.hostname)) && url.port && url.origin === saved) return saved; } catch {}
29
34
  }
30
35
  return `http://127.0.0.1:${await freePort()}`;
31
36
  }
@@ -110,7 +115,18 @@ async function writeSecrets(context, options) {
110
115
  async function moveSavedOrigin(context, options, values) {
111
116
  const key = options.originKey ?? 'AUTH_ORIGIN', origin = context.selectedOrigin;
112
117
  if (!origin || values[key] === undefined || values[key] === origin) return values;
113
- const current = await readSettings(context.root, options), format = options.format ?? 'dotenv', merged = { ...values, [key]: origin };
118
+ return rewriteOrigin(context.root, options, values, origin);
119
+ }
120
+
121
+ // Rewrite only the saved origin (rer dev --door and --direct). The caller holds
122
+ // the local operation lock.
123
+ export async function replaceSavedOrigin(root, options, origin) {
124
+ return rewriteOrigin(root, options, (await readSettings(root, options)).values, origin);
125
+ }
126
+
127
+ async function rewriteOrigin(root, options, values, origin) {
128
+ const key = options.originKey ?? 'AUTH_ORIGIN';
129
+ const current = await readSettings(root, options), format = options.format ?? 'dotenv', merged = { ...values, [key]: origin };
114
130
  const text = format === 'json' ? JSON.stringify(merged, null, 1) + '\n' : current.text.replace(new RegExp(`^${key}=.*$`, 'm'), `${key}=${dotenvValue(origin)}`);
115
131
  if (format !== 'json' && localVariables(text)[key] !== origin) throw new Error(`Could not move ${key} in ${options.file ?? '.dev.vars'}; put ${key}=${origin} on its own line.`);
116
132
  await writePrivateFile(current.path, text);
@@ -10,6 +10,7 @@ import { credentialValues } from './redaction.mjs';
10
10
  import { labels } from './adapter-labels.mjs';
11
11
  import { chooseOrigin, prepareSettings, readSettings } from './adapter-settings.mjs';
12
12
  import {listenerPorts} from './process-diagnostics.mjs';
13
+ import { appFetch } from './app-fetch.mjs';
13
14
  import { owner, signIn } from './adapter-auth.mjs';
14
15
  import { installerDevKey } from './dev-identity.mjs';
15
16
  import { assertArchiveFree } from './archive-output.mjs';
@@ -180,7 +181,7 @@ async function build(context, hooks) {
180
181
  // Health is the application's own /health: HTTP 200 with JSON status "ok"
181
182
  // unless the seed supplies another check. Each probe honours its signal.
182
183
  export async function defaultHealth(url, signal) {
183
- const response = await fetch(url, { signal });
184
+ const response = await appFetch(url, { signal });
184
185
  if (!response.ok) {
185
186
  await response.body?.cancel();
186
187
  return { ready: false, message: `HTTP ${response.status}` };
@@ -0,0 +1,79 @@
1
+ // Requests rer itself makes to a local app's own origin (health, sign-in,
2
+ // outbox, dev identity). A directly served origin uses fetch. A front-door
3
+ // origin (http://NAME.localhost:PORT) is never resolved and never goes through
4
+ // the door: rer connects with node:http to that checkout's own proxy on
5
+ // 127.0.0.1, which keeps the Host header (fetch drops a Host override, and musl
6
+ // and some Linux resolvers do not resolve *.localhost), and adds the proxy's
7
+ // per-start secret. Redirects within the routed origin are followed the way
8
+ // fetch follows them. A `*.localhost` origin with no route (its server is not
9
+ // running in this process's knowledge) is refused before anything is sent:
10
+ // whatever answers that name (the door, or a process that took its port) never
11
+ // receives rer's own requests, which can carry the checkout's dev key.
12
+ import http from 'node:http';
13
+ import { basename, dirname } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ // The shared loopback predicate, beside this copy in Base or in a seed's scripts/.
16
+ const portable = basename(dirname(fileURLToPath(import.meta.url))) === 'base-onboarding';
17
+ const { isLocalhostName } = await import(new URL(portable ? '../base-cli/modules/operator-auth/loopback.mjs' : '../modules/operator-auth/loopback.mjs', import.meta.url));
18
+
19
+ const routes = new Map();
20
+
21
+ /** Send this process's requests for `origin` to 127.0.0.1:`port` with `headers` added. */
22
+ export function routeOrigin(origin, { port, headers = {} }) {
23
+ routes.set(new URL(origin).origin, { port, headers });
24
+ }
25
+
26
+ /** One HTTP/1.1 exchange with a loopback listener, Host as given. */
27
+ export function loopbackRequest({ host = '127.0.0.1', port, path = '/', method = 'GET', headers = {}, body, signal, timeoutMs = 30000 }) {
28
+ return new Promise((resolve, reject) => {
29
+ const request = http.request({ host, port, path, method, headers, agent: false, signal }, response => {
30
+ const chunks = [];
31
+ response.on('data', chunk => chunks.push(chunk));
32
+ response.on('end', () => resolve({ status: response.statusCode, statusText: response.statusMessage ?? '', rawHeaders: response.rawHeaders, body: Buffer.concat(chunks) }));
33
+ response.on('error', reject);
34
+ });
35
+ request.setTimeout(timeoutMs, () => request.destroy(Object.assign(Error(`No answer from ${host}:${port} within ${timeoutMs / 1000} s.`), { code: 'ETIMEDOUT' })));
36
+ request.on('error', reject);
37
+ request.end(body);
38
+ });
39
+ }
40
+
41
+ async function bodyBytes(body) {
42
+ if (body === undefined || body === null) return undefined;
43
+ if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) return Buffer.from(body);
44
+ return Buffer.from(await new Response(body).arrayBuffer());
45
+ }
46
+
47
+ const nullBody = status => [101, 103, 204, 205, 304].includes(status);
48
+
49
+ function unrouted(url) {
50
+ return Object.assign(new TypeError(`fetch failed: rer sends nothing to ${url.origin} except through that checkout's own server, which is not running here.`), { code: 'ERR_RER_UNROUTED' });
51
+ }
52
+
53
+ export async function appFetch(input, init = {}) {
54
+ let url = new URL(input instanceof Request ? input.url : String(input));
55
+ if (!routes.has(url.origin) && isLocalhostName(url.hostname)) throw unrouted(url);
56
+ if (!routes.has(url.origin)) return fetch(input, init);
57
+ let method = (init.method ?? 'GET').toUpperCase(), body = await bodyBytes(init.body);
58
+ const sent = Object.fromEntries(new Headers(init.headers ?? {}));
59
+ if (body !== undefined && init.body instanceof URLSearchParams && !sent['content-type']) sent['content-type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
60
+ for (let hops = 0; ; hops++) {
61
+ const route = routes.get(url.origin);
62
+ if (!route && isLocalhostName(url.hostname)) throw unrouted(url);
63
+ if (!route) return fetch(url, { ...init, method, body });
64
+ const reply = await loopbackRequest({ port: route.port, path: url.pathname + url.search, method, headers: { ...sent, ...route.headers, host: url.host, ...(body ? { 'content-length': String(body.length) } : {}) }, body, signal: init.signal });
65
+ const headers = new Headers();
66
+ for (let i = 0; i < reply.rawHeaders.length; i += 2) headers.append(reply.rawHeaders[i], reply.rawHeaders[i + 1]);
67
+ const location = headers.get('location');
68
+ if (location && [301, 302, 303, 307, 308].includes(reply.status) && (init.redirect ?? 'follow') !== 'manual') {
69
+ if (init.redirect === 'error') throw new TypeError('fetch failed: redirect');
70
+ if (hops >= 20) throw new TypeError('fetch failed: too many redirects');
71
+ if (reply.status === 303 || ([301, 302].includes(reply.status) && method === 'POST')) { method = 'GET'; body = undefined; delete sent['content-type']; }
72
+ url = new URL(location, url);
73
+ continue;
74
+ }
75
+ const response = new Response(nullBody(reply.status) || method === 'HEAD' ? null : reply.body, { status: reply.status, statusText: reply.statusText, headers });
76
+ Object.defineProperty(response, 'url', { value: url.href });
77
+ return response;
78
+ }
79
+ }
@@ -1,3 +1,5 @@
1
1
  import type { Plugin } from 'vite';
2
- export function assertArchiveFree(directory: string): Promise<void>;
3
- export function archiveOutputGuard(): Plugin;
2
+ /** `formats`: the seed's own archive formats, matched against a leading JSON object's `payload.format`. */
3
+ export interface ArchiveFormats { formats?: string[] }
4
+ export function assertArchiveFree(directory: string, options?: ArchiveFormats): Promise<void>;
5
+ export function archiveOutputGuard(options?: ArchiveFormats): Plugin;
@@ -4,9 +4,13 @@ import { join, resolve } from 'node:path';
4
4
 
5
5
  // Stream output, including copied public assets: a renamed archive is still
6
6
  // private. Never include its contents in an error or read it into one buffer.
7
- async function hasManifest(file) {
7
+ // A seed whose own export writes another format declares that format's
8
+ // `payload.format` strings; a file whose leading JSON object carries one of them
9
+ // is an archive too.
10
+ async function hasManifest(file, formats) {
8
11
  let tail = '', kind = false, format = false, header = '', depth = 0, quoted = false, escaped = false, reading = true, started = false;
9
- const manifest = text => { try { const value = JSON.parse(text); return value?.kind === 'manifest' && value.format === 'runeditrun-portability'; } catch { return false; } };
12
+ const manifest = text => { try { const value = JSON.parse(text); return value?.kind === 'manifest' && value.format === 'runeditrun-portability' || formats.includes(value?.payload?.format); } catch { return false; } };
13
+ const declared = formats.map(name => new RegExp(`"format"\\s*:\\s*${JSON.stringify(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
10
14
  for await (const chunk of createReadStream(file, { encoding: 'utf8' })) {
11
15
  // Parse the leading JSON object too, so escaped property names and pretty
12
16
  // printing cannot hide an otherwise valid portability manifest. The format
@@ -14,7 +18,8 @@ async function hasManifest(file) {
14
18
  if (reading) for (const character of chunk) {
15
19
  if (!started) { if (/\s/.test(character)) continue; if (character !== '{') { reading = false; break; } started = true; }
16
20
  header += character;
17
- if (header.length > 24 * 1024 * 1024) { reading = false; header = ''; break; }
21
+ // A leading object past the limit is an archive when it names a declared format.
22
+ if (header.length > 24 * 1024 * 1024) { if (declared.some(pattern => pattern.test(header))) return true; reading = false; header = ''; break; }
18
23
  if (quoted) { if (escaped) escaped = false; else if (character === '\\') escaped = true; else if (character === '"') quoted = false; }
19
24
  else if (character === '"') quoted = true;
20
25
  else if (character === '{') depth++;
@@ -28,27 +33,34 @@ async function hasManifest(file) {
28
33
  }
29
34
  return false;
30
35
  }
31
- export async function assertArchiveFree(directory) {
36
+ export async function assertArchiveFree(directory, { formats = [] } = {}) {
37
+ assert(formats);
32
38
  const status = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
33
39
  if (!status) return;
34
40
  if (status.isSymbolicLink()) throw new Error('Build output must not contain symbolic links.');
35
41
  if (status.isDirectory()) {
36
- for (const name of await readdir(directory)) await assertArchiveFree(join(directory, name));
37
- } else if (status.isFile() && (/\.rer(?:\.[\w-]+\.partial)?$/i.test(directory) || await hasManifest(directory))) {
42
+ for (const name of await readdir(directory)) await assertArchiveFree(join(directory, name), { formats });
43
+ } else if (status.isFile() && (/\.rer(?:\.[\w-]+\.partial)?$/i.test(directory) || await hasManifest(directory, formats))) {
38
44
  throw new Error('Build output contains an installation archive. Move archives to exports/ and build again.');
39
45
  }
40
46
  }
41
47
 
48
+ function assert(formats) {
49
+ if (!Array.isArray(formats) || formats.some(name => typeof name !== 'string' || !name.trim())) throw new Error('Archive formats must be a list of non-empty payload.format strings.');
50
+ }
51
+
42
52
  // Runs after Vite copies publicDir and writes each client/Worker bundle, also
43
- // for direct vite build and custom output directories.
44
- export function archiveOutputGuard() {
53
+ // for direct vite build and custom output directories. `formats` lists the
54
+ // seed's own archive formats (`archiveOutputGuard({ formats: ['acme/local-data.v1'] })`).
55
+ export function archiveOutputGuard({ formats = [] } = {}) {
56
+ assert(formats);
45
57
  let root;
46
58
  return {
47
59
  name: 'rer:private-archives', apply: 'build', enforce: 'post',
48
60
  configResolved(config) { root = config.root; },
49
61
  writeBundle: { order: 'post', async handler(options) {
50
- if (options.dir) await assertArchiveFree(resolve(root, options.dir));
51
- else if (options.file) await assertArchiveFree(resolve(root, options.file));
62
+ if (options.dir) await assertArchiveFree(resolve(root, options.dir), { formats });
63
+ else if (options.file) await assertArchiveFree(resolve(root, options.file), { formats });
52
64
  } },
53
65
  };
54
66
  }
@@ -1,3 +1,4 @@
1
+ import { appFetch } from './app-fetch.mjs';
1
2
  // Setup opens app-local approvals before native bootstrap. Existing owners must
2
3
  // prove their email afresh through the local outbox, just like owner controls.
3
4
  export async function approveSetupDependencies(context, post, messages) {
@@ -5,7 +6,7 @@ export async function approveSetupDependencies(context, post, messages) {
5
6
  if(!selected.length)return;
6
7
  const failure=()=>Error('Application dependency approval failed. Complete fresh app-owner approval before linking.');
7
8
  let cookie;
8
- const current=await fetch(new URL('/auth/dependencies/status',context.origin),{signal:context.signal}).then(response=>response.ok?response.json():null);
9
+ const current=await appFetch(new URL('/auth/dependencies/status',context.origin),{signal:context.signal}).then(response=>response.ok?response.json():null);
9
10
  const files=process.env.RER_LINK_TOKEN_FILES?JSON.parse(process.env.RER_LINK_TOKEN_FILES):{};
10
11
  for(const dependency of selected) {
11
12
  const body={interface:dependency.interface,issuer:context.input[dependency.address]};
@@ -21,7 +22,7 @@ export async function approveSetupDependencies(context, post, messages) {
21
22
  let id;
22
23
  for(let attempt=0;attempt<50 && !id;attempt++) {id=(await messages(context,path,email)).find(value=>!before.has(value));if(!id)await new Promise(resolve=>setTimeout(resolve,200));}
23
24
  if(!id)throw failure();
24
- const response=await fetch(new URL(path+'?message='+encodeURIComponent(id),context.origin),{signal:context.signal,headers:context.devKey?{'x-rer-dev-key':context.devKey}:{}});
25
+ const response=await appFetch(new URL(path+'?message='+encodeURIComponent(id),context.origin),{signal:context.signal,headers:context.devKey?{'x-rer-dev-key':context.devKey}:{}});
25
26
  if(!response.ok)throw failure();
26
27
  const html=await response.text(),href=/<a\b[^>]*class="open"[^>]*href="([^"]+)"/.exec(html)?.[1];
27
28
  if(!href)throw failure();
@@ -30,7 +31,7 @@ export async function approveSetupDependencies(context, post, messages) {
30
31
  if(confirmed.status!==303)throw failure();cookie=confirmed.headers.get('set-cookie')?.split(';')[0];await confirmed.body?.cancel();
31
32
  if(!cookie)throw failure();context.store.addSecrets?.([cookie]);
32
33
  }
33
- const approved=await fetch(new URL('/auth/dependencies/relink',context.origin),{method:'POST',redirect:'manual',signal:context.signal,headers:{Origin:context.origin,'Content-Type':'application/json',Cookie:cookie},body:JSON.stringify(body)});
34
+ const approved=await appFetch(new URL('/auth/dependencies/relink',context.origin),{method:'POST',redirect:'manual',signal:context.signal,headers:{Origin:context.origin,'Content-Type':'application/json',Cookie:cookie},body:JSON.stringify(body)});
34
35
  if(!approved.ok)throw failure();await approved.body?.cancel();
35
36
  }
36
37
  }
@@ -1,4 +1,9 @@
1
1
  import { parseEnv } from 'node:util'
2
+ import { basename, dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ // The shared loopback predicate, beside this copy in Base or in a seed's scripts/.
5
+ const portable = basename(dirname(dirname(fileURLToPath(import.meta.url)))) === 'base-onboarding'
6
+ const { isLocalhostName } = await import(new URL(portable ? '../../base-cli/modules/operator-auth/loopback.mjs' : '../../modules/operator-auth/loopback.mjs', import.meta.url))
2
7
 
3
8
  export function localVariables(text) {
4
9
  // Node's dotenv parser can consume the next assignment after an unquoted
@@ -23,8 +28,8 @@ export const secretValues = (vars) => Object.entries(vars).filter(([key]) => /SE
23
28
  export function localOrigin(vars) {
24
29
  let url
25
30
  try { url = new URL(vars.AUTH_ORIGIN) } catch { /* Use a fixed, non-secret diagnostic. */ }
26
- if (vars.AUTH_LOCAL !== 'true' || !url || url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || url.origin !== vars.AUTH_ORIGIN || url.username || url.password) {
27
- throw new Error('Invalid local origin in .dev.vars: AUTH_ORIGIN must be http://127.0.0.1:PORT with AUTH_LOCAL=true. Run rer setup to save a valid one. If you changed AUTH_ORIGIN yourself, put back the http://127.0.0.1:PORT origin setup saved, or pick a port with rer setup --port PORT.')
31
+ if (vars.AUTH_LOCAL !== 'true' || !url || url.protocol !== 'http:' || (url.hostname !== '127.0.0.1' && !isLocalhostName(url.hostname)) || url.origin !== vars.AUTH_ORIGIN || url.username || url.password) {
32
+ throw new Error('Invalid local origin in .dev.vars: AUTH_ORIGIN must be http://127.0.0.1:PORT (or a front-door http://NAME.localhost:PORT) with AUTH_LOCAL=true. Run rer setup to save a valid one. If you changed AUTH_ORIGIN yourself, put back the http://127.0.0.1:PORT origin setup saved, or pick a port with rer setup --port PORT.')
28
33
  }
29
34
  return url
30
35
  }
@@ -1,11 +1,17 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
2
3
  import { readFile, unlink } from 'node:fs/promises';
3
4
  import { join, relative } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
4
6
  import { chooseOrigin } from './adapter-settings.mjs';
5
7
  import { privateDirectory, writePrivateFile } from './private-files.mjs';
6
8
  import { installationFailure, installationFailureMessage } from './failures.mjs';
7
9
  import { labels } from './adapter-labels.mjs';
8
10
 
11
+ // The rer that nested commands run: a vendored checkout's own scripts/rer.mjs,
12
+ // else this copy's own (a checkout that pins the @runeditrun/rer package).
13
+ const rerEntry=root=>existsSync(join(root,'scripts/rer.mjs'))?'scripts/rer.mjs':fileURLToPath(new URL('../bin/rer.mjs',import.meta.url));
14
+
9
15
  // The browser and terminal use the same bounded setup journey. Its temporary
10
16
  // runtime is gone before that lease releases. The foreground preview belongs
11
17
  // to the installer process, outside the bounded job's descendant tree.
@@ -20,14 +26,14 @@ export async function installOperator(root,input,store,onApp,signal,hooks){
20
26
  // The browser owns no local operation. A verifier may have launched it while
21
27
  // holding a different checkout's token; each public command takes its own.
22
28
  const env={...process.env};delete env.RER_LOCAL_OPERATION;
23
- const run=(label,args)=>store.run(label,process.execPath,['scripts/rer.mjs',...args,'--non-interactive','--json'],{cwd:root,signal,env,heavy:false});
29
+ const run=(label,args)=>store.run(label,process.execPath,[rerEntry(root),...args,'--non-interactive','--json'],{cwd:root,signal,env,heavy:false});
24
30
  step='dependencies';await store.step(labels.dependencies);await run(labels.dependencies,['install']);
25
31
  step='setup';await store.step(labels.build);await run(labels.build,['setup','--answers',relative(root,answer),'--host',address.hostname.replace(/^\[|\]$/g,''),'--port',address.port]);
26
32
  await unlink(answer);answer=undefined;
27
33
  // Preview's ready event carries RER_OPEN; keep the checkout's dev key out of this log.
28
34
  const devKey=/^RER_DEV_KEY=(\S+)$/m.exec(await readFile(join(root,'.rer/local/dev-key.env'),'utf8').catch(()=>''))?.[1];if(devKey)store.addSecrets?.([devKey]);
29
35
  step='start';await store.step(labels.start);
30
- app=store.spawn(labels.start,process.execPath,['scripts/rer.mjs','preview','--non-interactive','--json'],{cwd:root,signal,env});onApp(app);
36
+ app=store.spawn(labels.start,process.execPath,[rerEntry(root),'preview','--non-interactive','--json'],{cwd:root,signal,env});onApp(app);
31
37
  let buffer='',ready=false,openUrl;
32
38
  app.stdout.on('data',chunk=>{buffer+=chunk;let end;while((end=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,end);buffer=buffer.slice(end+1);try{const event=JSON.parse(line);if(event.version===1&&event.verb==='preview'&&event.event==='ready'&&event.url===origin){ready=true;openUrl=event.openUrl;}}catch{}}});
33
39
  await store.waitForReady(labels.start,app,async probeSignal=>{
@@ -18,8 +18,11 @@ export async function operatorPrerequisites(root,email){
18
18
  db=new DatabaseSync(join(directory,file.name),{readOnly:true});
19
19
  const applied=new Set(db.prepare('SELECT name FROM d1_migrations').all().map(row=>row.name));
20
20
  const claimed=db.prepare('SELECT COUNT(*) AS count FROM oa_bootstrap').get().count;
21
- const owner=db.prepare("SELECT COUNT(*) AS count FROM oa_operators WHERE email=? AND role='owner' AND status='active'").get(email).count;
22
- if(claimed===1&&owner===1&&migrations.every(name=>applied.has(name)))return true;
21
+ // Setup claimed this saved owner, and an active owner remains: after that
22
+ // owner has left, dev signs in as the remaining owner (dev identity's owner persona).
23
+ const saved=db.prepare('SELECT COUNT(*) AS count FROM oa_operators WHERE email=?').get(email).count;
24
+ const owners=db.prepare("SELECT COUNT(*) AS count FROM oa_operators WHERE role='owner' AND status='active'").get().count;
25
+ if(claimed===1&&saved===1&&owners>0&&migrations.every(name=>applied.has(name)))return true;
23
26
  }catch{/* A missing/corrupt/unrelated database cannot prove prior setup. */}
24
27
  finally{db?.close();}
25
28
  }
@@ -23,12 +23,16 @@ export async function queuedEntrypoint(url,verb='verify') {
23
23
  }finally{await store?.closeCommands();await rm(directory,{recursive:true,force:true});}
24
24
  }
25
25
 
26
- if(process.argv[1] && pathToFileURL(resolve(process.argv[1])).href===import.meta.url){
27
- const [verb,separator,command,...args]=process.argv.slice(2);
28
- if(separator!=='--'||!command)throw Error('Usage: queued-entry VERB -- COMMAND ARGS');
26
+ // `node queued-entry.mjs VERB -- COMMAND ARGS` (a vendored copy) and
27
+ // `rer queue VERB -- COMMAND ARGS` (a pinned package): run COMMAND once the
28
+ // machine job queue admits it, and return its exit status.
29
+ export async function queuedCommandLine([verb,separator,command,...args]){
30
+ if(!verb||separator!=='--'||!command)throw Error('Usage: queued-entry VERB -- COMMAND ARGS');
29
31
  const directory=await mkdtemp(join(tmpdir(),'rer-heavy-'));
30
32
  const store=await createCommands(directory,{logs:[]},{terminal:text=>process.stdout.write(text),queue:{verb}});
31
- try{await store.run(verb,command,args,{cwd:process.cwd()});}
32
- catch(error){process.exitCode=error.childExitCode??1;}
33
+ try{await store.run(verb,command,args,{cwd:process.cwd()});return 0;}
34
+ catch(error){return error.childExitCode??1;}
33
35
  finally{await store.closeCommands();await rm(directory,{recursive:true,force:true});}
34
36
  }
37
+
38
+ if(process.argv[1] && pathToFileURL(resolve(process.argv[1])).href===import.meta.url)process.exitCode=await queuedCommandLine(process.argv.slice(2));
@@ -1,5 +1,5 @@
1
1
  import { readFile, access } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { relative, resolve } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { spawnSync } from 'node:child_process';
@@ -136,8 +136,17 @@ async function checkSummary(result, descriptor, root) {
136
136
  return lines.join('\n');
137
137
  }
138
138
 
139
+ // A first-party checkout's rer: its vendored scripts/rer.mjs, or the
140
+ // @runeditrun/rer package it pins.
141
+ function checkoutRer(root) {
142
+ const vendored = resolve(root, 'scripts/rer.mjs'), pinned = resolve(root, 'node_modules/@runeditrun/rer/bin/rer.mjs');
143
+ if (existsSync(vendored)) return vendored;
144
+ try { const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); if ((pkg.devDependencies?.['@runeditrun/rer'] ?? pkg.dependencies?.['@runeditrun/rer']) && existsSync(pinned)) return pinned; } catch {}
145
+ return vendored;
146
+ }
147
+
139
148
  export async function main(args = process.argv.slice(2), root = process.cwd()) {
140
- const rer = resolve(root, 'scripts/rer.mjs');
149
+ const rer = checkoutRer(root);
141
150
  if (!['describe', 'check'].includes(args[0]) || (args.length !== 1 && !existsSync(rer))) throw new Error('Unsupported operations command.');
142
151
  // First-party projects describe and check through their RER command.
143
152
  if (existsSync(rer)) {
package/package.json CHANGED
@@ -1,12 +1,86 @@
1
1
  {
2
2
  "name": "@runeditrun/rer",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The rer command: install, set up, run and launch Run Edit Run checkouts.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "rer": "global/rer.mjs"
9
9
  },
10
+ "exports": {
11
+ "./adapter": "./cli/adapter.mjs",
12
+ "./deploy": "./cli/deploy.mjs",
13
+ "./portability": "./cli/portability.mjs",
14
+ "./errors": "./cli/errors.mjs",
15
+ "./jsonc": "./cli/jsonc.mjs",
16
+ "./parse": "./cli/parse.mjs",
17
+ "./scheduled": "./cli/scheduled.mjs",
18
+ "./dev-identity": "./cli/dev-identity.mjs",
19
+ "./local-installer": "./onboarding/adapter.mjs",
20
+ "./state": "./onboarding/state.mjs",
21
+ "./commands": "./onboarding/commands.mjs",
22
+ "./operator-installer": "./onboarding/operator-installer.mjs",
23
+ "./operator-install": "./onboarding/operator-install.mjs",
24
+ "./operator-prerequisites": "./onboarding/operator-prerequisites.mjs",
25
+ "./adapter-auth": "./onboarding/adapter-auth.mjs",
26
+ "./adapter-hosting": "./onboarding/adapter-hosting.mjs",
27
+ "./adapter-settings": "./onboarding/adapter-settings.mjs",
28
+ "./failures": "./onboarding/failures.mjs",
29
+ "./private-files": "./onboarding/private-files.mjs",
30
+ "./process-diagnostics": "./onboarding/process-diagnostics.mjs",
31
+ "./process-cleanup": "./onboarding/process-cleanup.mjs",
32
+ "./process-scope": "./onboarding/process-scope.mjs",
33
+ "./redaction": "./onboarding/redaction.mjs",
34
+ "./setup-manifest": "./onboarding/setup-manifest.mjs",
35
+ "./setup-schema": "./onboarding/schema/SETUP.schema.json",
36
+ "./local/environment": "./onboarding/local/environment.mjs",
37
+ "./local/files": "./onboarding/local/files.mjs",
38
+ "./local/build": "./onboarding/local/build.mjs",
39
+ "./local/operation": "./onboarding/local/operation.mjs",
40
+ "./local/recovery": "./onboarding/local/recovery.mjs",
41
+ "./local/state": "./onboarding/local/state.mjs",
42
+ "./production-runtime": "./onboarding/production-runtime.mjs",
43
+ "./production-modules": "./onboarding/production-modules.mjs",
44
+ "./queued-entry": "./onboarding/queued-entry.mjs",
45
+ "./archive-output": {
46
+ "types": "./onboarding/archive-output.d.mts",
47
+ "default": "./onboarding/archive-output.mjs"
48
+ },
49
+ "./launcher": "./onboarding/launcher.mjs",
50
+ "./installer-server": "./onboarding/server.mjs",
51
+ "./server-resources": "./cli/server-resources.mjs",
52
+ "./operations": "./operations/operations.mjs",
53
+ "./modules/portability": "./modules/portability/index.mjs",
54
+ "./modules/portability/catalogue": "./modules/portability/catalogue.mjs",
55
+ "./modules/portability/cli": "./modules/portability/cli.mjs",
56
+ "./modules/portability/cli-options": "./modules/portability/cli-options.mjs",
57
+ "./modules/portability/files": "./modules/portability/files.mjs",
58
+ "./modules/portability/format": "./modules/portability/format.mjs",
59
+ "./modules/portability/http": "./modules/portability/http.mjs",
60
+ "./modules/portability/import": "./modules/portability/import.mjs",
61
+ "./modules/portability/journal": "./modules/portability/journal.mjs",
62
+ "./modules/portability/legacy-safety": "./modules/portability/legacy-safety.mjs",
63
+ "./modules/portability/migration-history": "./modules/portability/migration-history.mjs",
64
+ "./modules/portability/storage": "./modules/portability/storage.mjs",
65
+ "./modules/erasure/fence": "./modules/erasure/fence.mjs",
66
+ "./modules/deploy-kit/owner": "./modules/deploy-kit/owner.mjs",
67
+ "./modules/deploy-kit/owner-cli": "./modules/deploy-kit/owner-cli.mjs",
68
+ "./modules/deploy-kit/prepare": "./modules/deploy-kit/prepare.mjs",
69
+ "./modules/deploy-kit/provider": "./modules/deploy-kit/provider.mjs",
70
+ "./modules/release/export": "./modules/release/export.mjs",
71
+ "./modules/release/publication-scan": "./modules/release/publication-scan.mjs",
72
+ "./modules/release/source": "./modules/release/source.mjs",
73
+ "./modules/operator-auth": "./modules/operator-auth/index.mjs",
74
+ "./modules/operator-auth/accounts/native": "./modules/operator-auth/accounts/native.mjs",
75
+ "./modules/operator-auth/dev": "./modules/operator-auth/dev/index.mjs",
76
+ "./modules/operator-auth/dev-admission": "./modules/operator-auth/dev-admission.mjs",
77
+ "./modules/operator-auth/dev/badge": "./modules/operator-auth/dev/badge.mjs",
78
+ "./modules/operator-auth/identity/email-link": "./modules/operator-auth/identity/email-link/index.mjs",
79
+ "./modules/owner-claim": "./modules/owner-claim/index.mjs",
80
+ "./modules/dependencies": "./modules/dependencies/index.mjs",
81
+ "./internal/*": "./*",
82
+ "./package.json": "./package.json"
83
+ },
10
84
  "engines": {
11
85
  "node": ">=22"
12
86
  }
@@ -51,6 +51,10 @@ CONTEXT_URL = re.compile(r'(?i)(?<![A-Za-z])(?:' + REPOSITORY_CONTEXT + r')'
51
51
  + r'''[`*_~\[\]\s"']*(?:''' + URL.pattern.replace('(?i)', '') + ')')
52
52
  LINK_URL = re.compile(r'(?i)\[(?P<label>[^]\n]+)\]\(\s*(?P<url>' + URL.pattern.replace('(?i)', '') + ')')
53
53
  REPOSITORY_LABEL = re.compile(r'(?i)\b(?:' + REPOSITORY_CONTEXT + r')\b')
54
+ # Words that make a nearby `owner/name` a repository rather than an identifier.
55
+ # A bare "source" is not one: "source commit/tree" is ordinary prose.
56
+ NEARBY_CONTEXT = re.compile(r'(?i)\b(?:github|repositor(?:y|ies)|repos?|forks?|forked|clones?|cloned|upstream|downstream|origin|submodules?)\b')
57
+ CONTEXT_WINDOW = 60
54
58
 
55
59
 
56
60
  def explicit_references(text, path):
@@ -76,6 +80,20 @@ class RepositoryObject(dict):
76
80
 
77
81
 
78
82
  def references(text, path, local_paths=frozenset(), project_identity=None):
83
+ stated, ambiguous = reference_sets(text, path, local_paths, project_identity)
84
+ return stated | ambiguous
85
+
86
+
87
+ def reference_sets(text, path, local_paths=frozenset(), project_identity=None):
88
+ """Split `owner/name` tokens into stated repositories and ambiguous ones.
89
+
90
+ A stated reference has GitHub context: a URL, a repository field, a labelled
91
+ or commanded mention, a repository route. An ambiguous token is a bare
92
+ word/word in prose (`commit/tree`, `rer-delegation/1`, `dmsans/v17`): it is
93
+ a repository only if one answers to that name privately, never because none
94
+ does.
95
+ """
96
+ ambiguous = set()
79
97
  repository_text = SERVICE_URL.sub('', text)
80
98
  found = {normalize(m.group(1)) for m in URL.finditer(repository_text)
81
99
  if not SPONSORS_URL.match(repository_text, m.start())}
@@ -106,8 +124,16 @@ def references(text, path, local_paths=frozenset(), project_identity=None):
106
124
  # A plain multi-segment path is ambiguous; GitHub's repository
107
125
  # routes, a labeled field or a GitHub command disambiguate it.
108
126
  labeled = explicit and (re.search(r'[:=|]', match['separator']) or re.match(r'(?i)github|gh\s', match['label']))
109
- if not suffix or REPOSITORY_ROUTE.match(suffix) or labeled:
127
+ if REPOSITORY_ROUTE.match(suffix) or labeled:
110
128
  found.add(normalize(repository))
129
+ elif not suffix:
130
+ if explicit:
131
+ weak = re.fullmatch(r'(?i)source', match['label']) and not re.search(r'(?i)repository|repo', markdown[match.start():match.start('reference')])
132
+ else:
133
+ line_start = markdown.rfind('\n', 0, match.start()) + 1
134
+ around = markdown[max(line_start, match.start() - CONTEXT_WINDOW):match.end() + CONTEXT_WINDOW].split('\n', 1)[0]
135
+ weak = not NEARBY_CONTEXT.search(around)
136
+ (ambiguous if weak else found).add(normalize(repository))
111
137
  elif METADATA.search(path) or SOURCE.search(path):
112
138
  if path.lower().endswith('.json'):
113
139
  try:
@@ -146,7 +172,7 @@ def references(text, path, local_paths=frozenset(), project_identity=None):
146
172
  found.update(normalize(m.group(1)) for m in TYPED_REPOSITORY.finditer(text))
147
173
  for declaration in declarations(text):
148
174
  found.update(normalize(m.group(1)) for m in DECLARED_SHORTHAND.finditer(declaration))
149
- return found
175
+ return found, ambiguous - found
150
176
 
151
177
 
152
178
  def local_dependencies(data, path):