@superblocksteam/vite-plugin-file-sync 2.0.123 → 2.0.124-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-service/agent/prompts/build-base-system-prompt.d.ts +16 -1
- package/dist/ai-service/agent/prompts/build-base-system-prompt.d.ts.map +1 -1
- package/dist/ai-service/agent/prompts/build-base-system-prompt.js +27 -1
- package/dist/ai-service/agent/prompts/build-base-system-prompt.js.map +1 -1
- package/dist/ai-service/agent/prompts/build-security-scan-prompt.d.ts.map +1 -1
- package/dist/ai-service/agent/prompts/build-security-scan-prompt.js +3 -1
- package/dist/ai-service/agent/prompts/build-security-scan-prompt.js.map +1 -1
- package/dist/ai-service/agent/tools/apis/api-comparator.d.ts.map +1 -1
- package/dist/ai-service/agent/tools/apis/api-comparator.js +1 -4
- package/dist/ai-service/agent/tools/apis/api-comparator.js.map +1 -1
- package/dist/ai-service/agent/tools/apis/api-testing-state.js +7 -0
- package/dist/ai-service/agent/tools/apis/api-testing-state.js.map +1 -1
- package/dist/ai-service/agent/tools/apis/get-api-docs.d.ts +1 -1
- package/dist/ai-service/agent/tools/apis/write-api.d.ts +2 -2
- package/dist/ai-service/agent/tools/build-capture-screenshot.d.ts.map +1 -1
- package/dist/ai-service/agent/tools/build-capture-screenshot.js +11 -33
- package/dist/ai-service/agent/tools/build-capture-screenshot.js.map +1 -1
- package/dist/ai-service/agent/tools/build-copy-directory.d.ts +1 -1
- package/dist/ai-service/agent/tools/build-finalize.d.ts.map +1 -1
- package/dist/ai-service/agent/tools/build-finalize.js +11 -2
- package/dist/ai-service/agent/tools/build-finalize.js.map +1 -1
- package/dist/ai-service/agent/tools/build-install-packages.d.ts +41 -1
- package/dist/ai-service/agent/tools/build-install-packages.d.ts.map +1 -1
- package/dist/ai-service/agent/tools/build-install-packages.js +208 -0
- package/dist/ai-service/agent/tools/build-install-packages.js.map +1 -1
- package/dist/ai-service/agent/tools/build-lookup-npm-package.d.ts +28 -0
- package/dist/ai-service/agent/tools/build-lookup-npm-package.d.ts.map +1 -0
- package/dist/ai-service/agent/tools/build-lookup-npm-package.js +78 -0
- package/dist/ai-service/agent/tools/build-lookup-npm-package.js.map +1 -0
- package/dist/ai-service/agent/tools/build-manage-checklist.d.ts +4 -4
- package/dist/ai-service/agent/tools/build-navigate-preview.d.ts +16 -0
- package/dist/ai-service/agent/tools/build-navigate-preview.d.ts.map +1 -0
- package/dist/ai-service/agent/tools/build-navigate-preview.js +68 -0
- package/dist/ai-service/agent/tools/build-navigate-preview.js.map +1 -0
- package/dist/ai-service/agent/tools/build-write-file.d.ts +1 -1
- package/dist/ai-service/agent/tools/databases/dev-database.d.ts +9 -9
- package/dist/ai-service/agent/tools/index.d.ts +2 -0
- package/dist/ai-service/agent/tools/index.d.ts.map +1 -1
- package/dist/ai-service/agent/tools/index.js +2 -0
- package/dist/ai-service/agent/tools/index.js.map +1 -1
- package/dist/ai-service/agent/tools/integrations/execute-request.d.ts +3 -3
- package/dist/ai-service/agent/tools/report-security-findings.d.ts +11 -5
- package/dist/ai-service/agent/tools/report-security-findings.d.ts.map +1 -1
- package/dist/ai-service/agent/tools/report-security-findings.js +1 -0
- package/dist/ai-service/agent/tools/report-security-findings.js.map +1 -1
- package/dist/ai-service/agent/tools.d.ts.map +1 -1
- package/dist/ai-service/agent/tools.js +3 -1
- package/dist/ai-service/agent/tools.js.map +1 -1
- package/dist/ai-service/agent/tools2/tools/git.d.ts +2 -2
- package/dist/ai-service/agent/tools2/tools/grep.d.ts +2 -2
- package/dist/ai-service/agent/tools2/tools/update-test-case-status.d.ts +2 -2
- package/dist/ai-service/app-interface/npm-error-parser.d.ts +161 -0
- package/dist/ai-service/app-interface/npm-error-parser.d.ts.map +1 -0
- package/dist/ai-service/app-interface/npm-error-parser.js +510 -0
- package/dist/ai-service/app-interface/npm-error-parser.js.map +1 -0
- package/dist/ai-service/app-interface/npm-package-lookup.d.ts +286 -0
- package/dist/ai-service/app-interface/npm-package-lookup.d.ts.map +1 -0
- package/dist/ai-service/app-interface/npm-package-lookup.js +793 -0
- package/dist/ai-service/app-interface/npm-package-lookup.js.map +1 -0
- package/dist/ai-service/app-interface/npm-registry.d.ts +735 -47
- package/dist/ai-service/app-interface/npm-registry.d.ts.map +1 -1
- package/dist/ai-service/app-interface/npm-registry.js +1882 -153
- package/dist/ai-service/app-interface/npm-registry.js.map +1 -1
- package/dist/ai-service/app-interface/shell.d.ts +118 -1
- package/dist/ai-service/app-interface/shell.d.ts.map +1 -1
- package/dist/ai-service/app-interface/shell.js +579 -157
- package/dist/ai-service/app-interface/shell.js.map +1 -1
- package/dist/ai-service/app-skills/manager.d.ts +1 -1
- package/dist/ai-service/app-skills/manager.d.ts.map +1 -1
- package/dist/ai-service/app-skills/manager.js +39 -7
- package/dist/ai-service/app-skills/manager.js.map +1 -1
- package/dist/ai-service/checklist/api-migration-checklist-gate.d.ts +9 -0
- package/dist/ai-service/checklist/api-migration-checklist-gate.d.ts.map +1 -0
- package/dist/ai-service/checklist/api-migration-checklist-gate.js +30 -0
- package/dist/ai-service/checklist/api-migration-checklist-gate.js.map +1 -0
- package/dist/ai-service/checklist/api-migration-origins.d.ts +4 -0
- package/dist/ai-service/checklist/api-migration-origins.d.ts.map +1 -0
- package/dist/ai-service/checklist/api-migration-origins.js +8 -0
- package/dist/ai-service/checklist/api-migration-origins.js.map +1 -0
- package/dist/ai-service/checklist/persisted-checklist-store.d.ts +2 -3
- package/dist/ai-service/checklist/persisted-checklist-store.d.ts.map +1 -1
- package/dist/ai-service/checklist/persisted-checklist-store.js +6 -7
- package/dist/ai-service/checklist/persisted-checklist-store.js.map +1 -1
- package/dist/ai-service/context-archive-paths.d.ts +4 -0
- package/dist/ai-service/context-archive-paths.d.ts.map +1 -0
- package/dist/ai-service/context-archive-paths.js +28 -0
- package/dist/ai-service/context-archive-paths.js.map +1 -0
- package/dist/ai-service/context-download.d.ts +3 -1
- package/dist/ai-service/context-download.d.ts.map +1 -1
- package/dist/ai-service/context-download.js +48 -9
- package/dist/ai-service/context-download.js.map +1 -1
- package/dist/ai-service/context-upload.d.ts +3 -2
- package/dist/ai-service/context-upload.d.ts.map +1 -1
- package/dist/ai-service/context-upload.js +44 -26
- package/dist/ai-service/context-upload.js.map +1 -1
- package/dist/ai-service/dev-database-client.d.ts +3 -11
- package/dist/ai-service/dev-database-client.d.ts.map +1 -1
- package/dist/ai-service/dev-database-client.js.map +1 -1
- package/dist/ai-service/features.d.ts +4 -0
- package/dist/ai-service/features.d.ts.map +1 -1
- package/dist/ai-service/features.js +8 -0
- package/dist/ai-service/features.js.map +1 -1
- package/dist/ai-service/filter-disabled-tools-for-migration.d.ts.map +1 -1
- package/dist/ai-service/filter-disabled-tools-for-migration.js +4 -0
- package/dist/ai-service/filter-disabled-tools-for-migration.js.map +1 -1
- package/dist/ai-service/index.d.ts +94 -0
- package/dist/ai-service/index.d.ts.map +1 -1
- package/dist/ai-service/index.js +564 -17
- package/dist/ai-service/index.js.map +1 -1
- package/dist/ai-service/llm/client.d.ts +38 -3
- package/dist/ai-service/llm/client.d.ts.map +1 -1
- package/dist/ai-service/llm/client.js +73 -22
- package/dist/ai-service/llm/client.js.map +1 -1
- package/dist/ai-service/llm/context-v2/adapter.d.ts +3 -1
- package/dist/ai-service/llm/context-v2/adapter.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/adapter.js +11 -11
- package/dist/ai-service/llm/context-v2/adapter.js.map +1 -1
- package/dist/ai-service/llm/context-v2/compaction/client-side.d.ts +44 -0
- package/dist/ai-service/llm/context-v2/compaction/client-side.d.ts.map +1 -0
- package/dist/ai-service/llm/context-v2/compaction/client-side.js +170 -0
- package/dist/ai-service/llm/context-v2/compaction/client-side.js.map +1 -0
- package/dist/ai-service/llm/context-v2/compaction/compaction-strategy.d.ts +56 -0
- package/dist/ai-service/llm/context-v2/compaction/compaction-strategy.d.ts.map +1 -0
- package/dist/ai-service/llm/context-v2/compaction/compaction-strategy.js +9 -0
- package/dist/ai-service/llm/context-v2/compaction/compaction-strategy.js.map +1 -0
- package/dist/ai-service/llm/context-v2/compaction/index.d.ts +5 -0
- package/dist/ai-service/llm/context-v2/compaction/index.d.ts.map +1 -0
- package/dist/ai-service/llm/context-v2/compaction/index.js +3 -0
- package/dist/ai-service/llm/context-v2/compaction/index.js.map +1 -0
- package/dist/ai-service/llm/context-v2/compaction/server-side.d.ts +30 -0
- package/dist/ai-service/llm/context-v2/compaction/server-side.d.ts.map +1 -0
- package/dist/ai-service/llm/context-v2/compaction/server-side.js +130 -0
- package/dist/ai-service/llm/context-v2/compaction/server-side.js.map +1 -0
- package/dist/ai-service/llm/context-v2/context-management.d.ts +203 -0
- package/dist/ai-service/llm/context-v2/context-management.d.ts.map +1 -0
- package/dist/ai-service/llm/context-v2/context-management.js +183 -0
- package/dist/ai-service/llm/context-v2/context-management.js.map +1 -0
- package/dist/ai-service/llm/context-v2/context.d.ts +64 -22
- package/dist/ai-service/llm/context-v2/context.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/context.js +233 -157
- package/dist/ai-service/llm/context-v2/context.js.map +1 -1
- package/dist/ai-service/llm/context-v2/conversation-context.d.ts +24 -7
- package/dist/ai-service/llm/context-v2/conversation-context.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/conversation-context.js +1 -1
- package/dist/ai-service/llm/context-v2/index.d.ts +0 -4
- package/dist/ai-service/llm/context-v2/index.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/index.js +0 -4
- package/dist/ai-service/llm/context-v2/index.js.map +1 -1
- package/dist/ai-service/llm/context-v2/manager.d.ts +24 -3
- package/dist/ai-service/llm/context-v2/manager.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/manager.js +146 -4
- package/dist/ai-service/llm/context-v2/manager.js.map +1 -1
- package/dist/ai-service/llm/context-v2/migrations/v1-to-v2.d.ts +2 -7
- package/dist/ai-service/llm/context-v2/migrations/v1-to-v2.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/migrations/v1-to-v2.js +5 -73
- package/dist/ai-service/llm/context-v2/migrations/v1-to-v2.js.map +1 -1
- package/dist/ai-service/llm/context-v2/storage/event-store.d.ts +57 -0
- package/dist/ai-service/llm/context-v2/storage/event-store.d.ts.map +1 -0
- package/dist/ai-service/llm/context-v2/storage/event-store.js +10 -0
- package/dist/ai-service/llm/context-v2/storage/event-store.js.map +1 -0
- package/dist/ai-service/llm/context-v2/storage/event-types.d.ts +50 -0
- package/dist/ai-service/llm/context-v2/storage/event-types.d.ts.map +1 -0
- package/dist/ai-service/llm/context-v2/storage/event-types.js +10 -0
- package/dist/ai-service/llm/context-v2/storage/event-types.js.map +1 -0
- package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.d.ts +87 -0
- package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.d.ts.map +1 -0
- package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.js +184 -0
- package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.js.map +1 -0
- package/dist/ai-service/llm/context-v2/storage/migration.d.ts +49 -0
- package/dist/ai-service/llm/context-v2/storage/migration.d.ts.map +1 -0
- package/dist/ai-service/llm/context-v2/storage/migration.js +126 -0
- package/dist/ai-service/llm/context-v2/storage/migration.js.map +1 -0
- package/dist/ai-service/llm/context-v2/types.d.ts +16 -11
- package/dist/ai-service/llm/context-v2/types.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/types.js +2 -2
- package/dist/ai-service/llm/context-v2/types.js.map +1 -1
- package/dist/ai-service/llm/impl/clark.d.ts +8 -0
- package/dist/ai-service/llm/impl/clark.d.ts.map +1 -1
- package/dist/ai-service/llm/impl/clark.js +8 -0
- package/dist/ai-service/llm/impl/clark.js.map +1 -1
- package/dist/ai-service/llm/interaction/adapters/vercel.d.ts.map +1 -1
- package/dist/ai-service/llm/interaction/adapters/vercel.js +17 -1
- package/dist/ai-service/llm/interaction/adapters/vercel.js.map +1 -1
- package/dist/ai-service/llm/provider.d.ts.map +1 -1
- package/dist/ai-service/llm/provider.js +1 -3
- package/dist/ai-service/llm/provider.js.map +1 -1
- package/dist/ai-service/llm/stream/observers/context.d.ts +23 -0
- package/dist/ai-service/llm/stream/observers/context.d.ts.map +1 -1
- package/dist/ai-service/llm/stream/observers/context.js +62 -0
- package/dist/ai-service/llm/stream/observers/context.js.map +1 -1
- package/dist/ai-service/llm/tool-context-integrity.d.ts +25 -0
- package/dist/ai-service/llm/tool-context-integrity.d.ts.map +1 -0
- package/dist/ai-service/llm/tool-context-integrity.js +141 -0
- package/dist/ai-service/llm/tool-context-integrity.js.map +1 -0
- package/dist/ai-service/skills/system/superblocks-migration/skill.generated.d.ts +1 -1
- package/dist/ai-service/skills/system/superblocks-migration/skill.generated.d.ts.map +1 -1
- package/dist/ai-service/skills/system/superblocks-migration/skill.generated.js +6 -1
- package/dist/ai-service/skills/system/superblocks-migration/skill.generated.js.map +1 -1
- package/dist/ai-service/skills/system/third-party-migration/skill.generated.d.ts +1 -1
- package/dist/ai-service/skills/system/third-party-migration/skill.generated.d.ts.map +1 -1
- package/dist/ai-service/skills/system/third-party-migration/skill.generated.js +3 -2
- package/dist/ai-service/skills/system/third-party-migration/skill.generated.js.map +1 -1
- package/dist/ai-service/state-machine/clark-fsm.d.ts +23 -2
- package/dist/ai-service/state-machine/clark-fsm.d.ts.map +1 -1
- package/dist/ai-service/state-machine/clark-fsm.js +37 -2
- package/dist/ai-service/state-machine/clark-fsm.js.map +1 -1
- package/dist/ai-service/state-machine/handlers/agent-planning.d.ts.map +1 -1
- package/dist/ai-service/state-machine/handlers/agent-planning.js +29 -2
- package/dist/ai-service/state-machine/handlers/agent-planning.js.map +1 -1
- package/dist/ai-service/state-machine/handlers/awaiting-user.d.ts.map +1 -1
- package/dist/ai-service/state-machine/handlers/awaiting-user.js +9 -0
- package/dist/ai-service/state-machine/handlers/awaiting-user.js.map +1 -1
- package/dist/ai-service/state-machine/handlers/llm-generating.d.ts.map +1 -1
- package/dist/ai-service/state-machine/handlers/llm-generating.js +25 -2
- package/dist/ai-service/state-machine/handlers/llm-generating.js.map +1 -1
- package/dist/ai-service/state-machine/helpers/fetch-with-reconnect-retry.d.ts +6 -0
- package/dist/ai-service/state-machine/helpers/fetch-with-reconnect-retry.d.ts.map +1 -0
- package/dist/ai-service/state-machine/helpers/fetch-with-reconnect-retry.js +36 -0
- package/dist/ai-service/state-machine/helpers/fetch-with-reconnect-retry.js.map +1 -0
- package/dist/ai-service/state-machine/helpers/stable-peer.d.ts +30 -1
- package/dist/ai-service/state-machine/helpers/stable-peer.d.ts.map +1 -1
- package/dist/ai-service/state-machine/helpers/stable-peer.js +85 -2
- package/dist/ai-service/state-machine/helpers/stable-peer.js.map +1 -1
- package/dist/ai-service/state-machine/helpers/user-preferences.d.ts +8 -0
- package/dist/ai-service/state-machine/helpers/user-preferences.d.ts.map +1 -0
- package/dist/ai-service/state-machine/helpers/user-preferences.js +11 -0
- package/dist/ai-service/state-machine/helpers/user-preferences.js.map +1 -0
- package/dist/ai-service/template-renderer.d.ts +18 -1
- package/dist/ai-service/template-renderer.d.ts.map +1 -1
- package/dist/ai-service/template-renderer.js +73 -12
- package/dist/ai-service/template-renderer.js.map +1 -1
- package/dist/ai-service/types.d.ts +157 -0
- package/dist/ai-service/types.d.ts.map +1 -1
- package/dist/ai-service/types.js +137 -0
- package/dist/ai-service/types.js.map +1 -1
- package/dist/ai-service/util/llm-config-utils.d.ts +21 -22
- package/dist/ai-service/util/llm-config-utils.d.ts.map +1 -1
- package/dist/ai-service/util/llm-config-utils.js +40 -67
- package/dist/ai-service/util/llm-config-utils.js.map +1 -1
- package/dist/file-sync-vite-plugin.d.ts.map +1 -1
- package/dist/file-sync-vite-plugin.js +5 -0
- package/dist/file-sync-vite-plugin.js.map +1 -1
- package/dist/file-system-helpers.d.ts +17 -0
- package/dist/file-system-helpers.d.ts.map +1 -1
- package/dist/file-system-helpers.js +22 -0
- package/dist/file-system-helpers.js.map +1 -1
- package/dist/git-service/index.d.ts.map +1 -1
- package/dist/git-service/index.js +15 -1
- package/dist/git-service/index.js.map +1 -1
- package/dist/migration/migration-routes.d.ts.map +1 -1
- package/dist/migration/migration-routes.js +26 -3
- package/dist/migration/migration-routes.js.map +1 -1
- package/dist/migration/translation-prompt.d.ts.map +1 -1
- package/dist/migration/translation-prompt.js +5 -0
- package/dist/migration/translation-prompt.js.map +1 -1
- package/dist/migration-templates/app-fullstack/package.json +2 -2
- package/dist/policy-gate-callback-mapper.d.ts +24 -0
- package/dist/policy-gate-callback-mapper.d.ts.map +1 -0
- package/dist/policy-gate-callback-mapper.js +61 -0
- package/dist/policy-gate-callback-mapper.js.map +1 -0
- package/dist/policy-gate-runner.d.ts +32 -0
- package/dist/policy-gate-runner.d.ts.map +1 -0
- package/dist/policy-gate-runner.js +191 -0
- package/dist/policy-gate-runner.js.map +1 -0
- package/dist/router-parser.d.ts.map +1 -1
- package/dist/router-parser.js +107 -11
- package/dist/router-parser.js.map +1 -1
- package/dist/sync-service/list-dir.d.ts.map +1 -1
- package/dist/sync-service/list-dir.js +13 -3
- package/dist/sync-service/list-dir.js.map +1 -1
- package/package.json +28 -10
|
@@ -1,6 +1,82 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { chmod, link, mkdir, open, readFile, rename, stat, unlink, writeFile, } from "node:fs/promises";
|
|
2
4
|
import * as path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import ini from "ini";
|
|
7
|
+
import { z } from "zod";
|
|
3
8
|
import { getLogger } from "../../util/logger.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
/**
|
|
11
|
+
* Wire schema for the `{ registries: [...] }` envelope returned by
|
|
12
|
+
* `GET /api/v1/organizations/:orgId/npm-registry`. Used by the client's
|
|
13
|
+
* defensive parse so a malformed payload routes to `serveStale…` instead
|
|
14
|
+
* of crashing the install path.
|
|
15
|
+
*
|
|
16
|
+
* The refinements (no embedded C0 control chars in any string that flows
|
|
17
|
+
* into `.npmrc`, scope matches npm's grammar, registry URL is https) are
|
|
18
|
+
* the CLI's last line of defense against a malicious admin or buggy server.
|
|
19
|
+
* The server validates the same shape on write, but `.npmrc` synthesis is
|
|
20
|
+
* security-sensitive enough that we re-validate at read time too.
|
|
21
|
+
*/
|
|
22
|
+
// eslint-disable-next-line no-control-regex
|
|
23
|
+
const noControlChars = (s) => !/[\x00-\x1f]/.test(s);
|
|
24
|
+
// Mirrors the server's npm scope grammar: must start with `@`, then a
|
|
25
|
+
// lowercase letter/digit, then up to 254 lowercase letters/digits/`._-`.
|
|
26
|
+
const NPM_SCOPE_RE = /^@[a-z0-9][a-z0-9._-]{0,254}$/;
|
|
27
|
+
const registryEntrySchema = z.object({
|
|
28
|
+
id: z.string().uuid(),
|
|
29
|
+
scope: z
|
|
30
|
+
.string()
|
|
31
|
+
.refine(noControlChars, "scope contains a C0 control character")
|
|
32
|
+
.refine((s) => NPM_SCOPE_RE.test(s), "scope is not a valid npm scope")
|
|
33
|
+
.nullable(),
|
|
34
|
+
registryUrl: z
|
|
35
|
+
.string()
|
|
36
|
+
.url()
|
|
37
|
+
.refine(noControlChars, "registryUrl contains a C0 control character")
|
|
38
|
+
.refine((s) => s.startsWith("https://"), "registryUrl must be https"),
|
|
39
|
+
// Matches the server's `OrgNpmRegistryDto.token` (see
|
|
40
|
+
// `packages/server/src/controllers/v1/orgNpmRegistry/handlers.ts`),
|
|
41
|
+
// which is documented as `string | null` but in practice may also be
|
|
42
|
+
// omitted from the wire payload entirely on older server builds.
|
|
43
|
+
// `.nullish()` accepts `null` AND missing/undefined; the transform
|
|
44
|
+
// coalesces both to `null` so downstream code keeps the canonical
|
|
45
|
+
// `NpmRegistryEntry.token: string | null` shape and never sees
|
|
46
|
+
// `undefined`. An absent token is a legitimate "unauthenticated
|
|
47
|
+
// registry" state, NOT wire-shape drift — treating it as drift would
|
|
48
|
+
// route through `serveStaleOrUnconfigured` and brick the install
|
|
49
|
+
// path on orgs whose registry intentionally has no token.
|
|
50
|
+
token: z
|
|
51
|
+
.string()
|
|
52
|
+
.refine(noControlChars, "token contains a C0 control character")
|
|
53
|
+
.nullish()
|
|
54
|
+
.transform((v) => v ?? null),
|
|
55
|
+
});
|
|
56
|
+
const registryListSchema = z.object({
|
|
57
|
+
registries: z.array(registryEntrySchema),
|
|
58
|
+
/**
|
|
59
|
+
* Org-level npm policy field from APPS-4250. Optional on the wire because
|
|
60
|
+
* the server only includes it when the `superblocks.npm-registry.enabled`
|
|
61
|
+
* LD flag is on; an older server or a flag-off response just omits the
|
|
62
|
+
* field. Treated as `undefined` everywhere downstream so consumers can
|
|
63
|
+
* default to "scripts allowed" (today's behaviour) without a special
|
|
64
|
+
* code path.
|
|
65
|
+
*/
|
|
66
|
+
npmAllowInstallScripts: z.boolean().optional(),
|
|
67
|
+
});
|
|
68
|
+
/**
|
|
69
|
+
* Wire envelope every Superblocks server endpoint wraps its JSON response in
|
|
70
|
+
* (`ResponseDto` in `packages/server/src/dto/response.ts`). We only need
|
|
71
|
+
* `data` here; `responseMeta` is unused for this endpoint and intentionally
|
|
72
|
+
* passes through unparsed. The outer `passthrough()` lets future
|
|
73
|
+
* `responseMeta`-shape drift not break the npm-registry path.
|
|
74
|
+
*/
|
|
75
|
+
const envelopeSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
data: z.unknown(),
|
|
78
|
+
})
|
|
79
|
+
.passthrough();
|
|
4
80
|
/**
|
|
5
81
|
* Scopes whose existing `<scope>:registry=` lines in a project / pod `.npmrc`
|
|
6
82
|
* must survive `writeNpmrc` calls. Shared between AppShell, TemplateRenderer,
|
|
@@ -12,123 +88,605 @@ import { getLogger } from "../../util/logger.js";
|
|
|
12
88
|
*/
|
|
13
89
|
export const PRESERVE_NPMRC_SCOPES = ["@superblocksteam"];
|
|
14
90
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
91
|
+
* Resolves the org `allow_install_scripts` policy from a `prepareForPrivateRegistry`
|
|
92
|
+
* (or `maybeWriteNpmrcForDir`) result. `false` means "the org has explicitly
|
|
93
|
+
* disabled npm install scripts" — install entrypoints append `--ignore-scripts`.
|
|
94
|
+
* `true` or `undefined` (server omitted the field, LD flag off, no client wired
|
|
95
|
+
* up) keeps today's behaviour.
|
|
96
|
+
*
|
|
97
|
+
* Shared by AppShell install entrypoints (shell.ts) and the dev-server startup
|
|
98
|
+
* install (sdk/cli-replacement/dev.mts) so both paths interpret the policy
|
|
99
|
+
* identically.
|
|
17
100
|
*/
|
|
18
|
-
function
|
|
19
|
-
return
|
|
101
|
+
export function shouldIgnoreInstallScripts(result) {
|
|
102
|
+
return result?.config.allowInstallScripts === false;
|
|
20
103
|
}
|
|
21
104
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
105
|
+
* Translates a list of server-shaped registry entries into the renderable
|
|
106
|
+
* `NpmRegistryConfig`. Centralised so `writeNpmrc`, tests, and any future
|
|
107
|
+
* consumer all interpret the wire shape the same way.
|
|
108
|
+
*
|
|
109
|
+
* Multiple `scope === null` entries should never happen — the server's
|
|
110
|
+
* UNIQUE (organization_id, scope) constraint forbids it — but we are
|
|
111
|
+
* defensive: the FIRST default wins, subsequent ones are logged and
|
|
112
|
+
* discarded so a server-side bug never silently rotates the default URL
|
|
113
|
+
* mid-fetch.
|
|
114
|
+
*
|
|
115
|
+
* Duplicate scoped entries (same `scope` string) follow the same rule:
|
|
116
|
+
* first wins.
|
|
28
117
|
*/
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
118
|
+
export function registriesToConfig(entries, allowInstallScripts) {
|
|
119
|
+
if (entries.length === 0) {
|
|
120
|
+
return {
|
|
121
|
+
configured: false,
|
|
122
|
+
...(allowInstallScripts !== undefined ? { allowInstallScripts } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
let defaultReg;
|
|
126
|
+
const scopes = {};
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
const value = {
|
|
129
|
+
url: entry.registryUrl,
|
|
130
|
+
...(entry.token ? { token: entry.token } : {}),
|
|
131
|
+
};
|
|
132
|
+
if (entry.scope === null) {
|
|
133
|
+
if (defaultReg) {
|
|
134
|
+
getLogger().warn("[npm-registry] server returned multiple default registry entries; keeping the first", { ignoredId: entry.id });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
defaultReg = value;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
if (scopes[entry.scope]) {
|
|
141
|
+
getLogger().warn("[npm-registry] server returned multiple entries for the same scope; keeping the first", { scope: entry.scope, ignoredId: entry.id });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
scopes[entry.scope] = value;
|
|
36
145
|
}
|
|
37
|
-
return u.toString();
|
|
38
146
|
}
|
|
39
|
-
|
|
40
|
-
|
|
147
|
+
return {
|
|
148
|
+
configured: true,
|
|
149
|
+
...(defaultReg ? { default: defaultReg } : {}),
|
|
150
|
+
...(Object.keys(scopes).length > 0 ? { scopes } : {}),
|
|
151
|
+
...(allowInstallScripts !== undefined ? { allowInstallScripts } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
155
|
+
// Deep-frozen so the `return NOT_CONFIGURED` sites can hand back the same
|
|
156
|
+
// reference without risking cross-caller contamination. Mutation attempts
|
|
157
|
+
// (e.g., a future caller doing `result.config.scopes = {...}` to "augment"
|
|
158
|
+
// before forwarding) throw a TypeError in strict mode rather than silently
|
|
159
|
+
// corrupting every subsequent caller for the process lifetime.
|
|
160
|
+
//
|
|
161
|
+
// Used only when the policy field is unknown (LD flag off on the client OR
|
|
162
|
+
// no cache to fall back to). When the policy IS known (e.g. server returned
|
|
163
|
+
// `npmAllowInstallScripts: false` alongside an empty `registries` list), the
|
|
164
|
+
// helper below builds a fresh result so the policy still reaches AppShell.
|
|
165
|
+
const NOT_CONFIGURED = Object.freeze({
|
|
166
|
+
source: "not-configured",
|
|
167
|
+
config: Object.freeze({ configured: false }),
|
|
168
|
+
});
|
|
169
|
+
// Same deep-freeze rationale as NOT_CONFIGURED. Returned only when the
|
|
170
|
+
// server is unreachable AND we have no usable cache, so the caller can
|
|
171
|
+
// distinguish "we don't know" from a deliberate `not-configured`
|
|
172
|
+
// observation.
|
|
173
|
+
const UNREACHABLE = Object.freeze({
|
|
174
|
+
source: "unreachable",
|
|
175
|
+
config: Object.freeze({ configured: false }),
|
|
176
|
+
});
|
|
177
|
+
/**
|
|
178
|
+
* Build a "not-configured" result that still carries the org policy when
|
|
179
|
+
* the server returned one. Falls back to the frozen `NOT_CONFIGURED`
|
|
180
|
+
* singleton when the policy is unknown so cold-path callers keep allocating
|
|
181
|
+
* zero objects.
|
|
182
|
+
*
|
|
183
|
+
* The result and its `config` are both frozen for the same reason as
|
|
184
|
+
* `NOT_CONFIGURED`: a downstream caller doing `result.config.scopes = {...}`
|
|
185
|
+
* to "augment before forwarding" throws a TypeError in strict mode rather
|
|
186
|
+
* than silently producing a result that doesn't match what we returned.
|
|
187
|
+
*/
|
|
188
|
+
function notConfigured(allowInstallScripts) {
|
|
189
|
+
if (allowInstallScripts === undefined) {
|
|
190
|
+
return NOT_CONFIGURED;
|
|
41
191
|
}
|
|
192
|
+
return Object.freeze({
|
|
193
|
+
source: "not-configured",
|
|
194
|
+
config: Object.freeze({ configured: false, allowInstallScripts }),
|
|
195
|
+
});
|
|
42
196
|
}
|
|
43
197
|
/**
|
|
44
|
-
*
|
|
198
|
+
* Same shape as `notConfigured(policy)` but for the "unreachable" branch:
|
|
199
|
+
* server didn't respond AND we have no usable cache. Destructive cleanup
|
|
200
|
+
* paths (the home-npmrc writer in `cli-replacement/dev.mts`) branch on the
|
|
201
|
+
* `unreachable` discriminator to refuse acting on a transient outage. The
|
|
202
|
+
* optional `allowInstallScripts` mirrors the `notConfigured` policy
|
|
203
|
+
* preservation invariant so AppShell still honors a known `--ignore-scripts`
|
|
204
|
+
* policy during the outage.
|
|
45
205
|
*/
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
206
|
+
function unreachable(allowInstallScripts) {
|
|
207
|
+
if (allowInstallScripts === undefined) {
|
|
208
|
+
return UNREACHABLE;
|
|
209
|
+
}
|
|
210
|
+
return Object.freeze({
|
|
211
|
+
source: "unreachable",
|
|
212
|
+
config: Object.freeze({ configured: false, allowInstallScripts }),
|
|
213
|
+
});
|
|
49
214
|
}
|
|
50
215
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* -
|
|
60
|
-
*
|
|
61
|
-
*
|
|
216
|
+
* Fetches per-org npm registry configuration from the server, caching the
|
|
217
|
+
* result in-memory with a short TTL. Designed so callers do not have to
|
|
218
|
+
* think about the LaunchDarkly flag, JWT lifecycle, or transient server
|
|
219
|
+
* outages — they just `await client.getConfig()` and get back a renderable
|
|
220
|
+
* shape (or "not configured").
|
|
221
|
+
*
|
|
222
|
+
* Lifecycle invariants (see APPS-4230 scope):
|
|
223
|
+
*
|
|
224
|
+
* 1. **Flag off → short-circuit**: returns `{ configured: false }` with
|
|
225
|
+
* no network I/O. Lets us dark-ship LinkedIn first without hammering
|
|
226
|
+
* the API for the rest of the fleet.
|
|
227
|
+
* 2. **Cache hit within TTL → no fetch**: avoids per-install round-trips
|
|
228
|
+
* against the server.
|
|
229
|
+
* 3. **401 → await `refreshJwt` then retry once with a fresh
|
|
230
|
+
* `getJwt()` read**: any push-based JWT refresh (auth-hot-reload)
|
|
231
|
+
* that landed between the original request and the retry is picked
|
|
232
|
+
* up. The cache is preserved across the retry so a failed retry
|
|
233
|
+
* can still fall back to last-known-good. Two disjoint failure
|
|
234
|
+
* modes:
|
|
235
|
+
* - The retry itself returns 401 → hard throw. We deliberately do
|
|
236
|
+
* not keep retrying because the only way a freshly-read JWT also
|
|
237
|
+
* 401s is a true re-auth failure.
|
|
238
|
+
* - `refreshJwt` itself throws or times out (e.g. the push channel
|
|
239
|
+
* hung) → route through `serveStaleOrUnconfigured` (same as a 5xx
|
|
240
|
+
* / network outage). A flaky refresh channel must not crash the
|
|
241
|
+
* install path, and we never reach the second `attempt()`.
|
|
242
|
+
* 4. **Other 4xx (403/400/…) → clear the cache and throw**: these are
|
|
243
|
+
* deliberate denials (RBAC revocation, malformed request), not
|
|
244
|
+
* transient outages. Serving stale would extend the revocation
|
|
245
|
+
* window beyond the cache TTL. The error propagates to the caller
|
|
246
|
+
* (typically `AppShell` / `TemplateRenderer`), which decides
|
|
247
|
+
* whether to fail the install or fall back to public npm.
|
|
248
|
+
* 5. **5xx or network unreachable + cached value → return cache +
|
|
249
|
+
* `logger.warn`**: install workflows continue against the
|
|
250
|
+
* last-known-good registry while we surface the outage; strictly
|
|
251
|
+
* better than falling back to `registry.npmjs.org` mid-deploy.
|
|
252
|
+
* 6. **5xx or network unreachable + no usable cache → return
|
|
253
|
+
* `source: "unreachable"` with `{ configured: false }`**: cold start
|
|
254
|
+
* or only an empty-list cache. `config.configured: false` so
|
|
255
|
+
* write-side consumers continue to no-op, but the distinct source
|
|
256
|
+
* keeps destructive cleanup paths (home-npmrc unlink) from acting on
|
|
257
|
+
* a transient outage.
|
|
258
|
+
*
|
|
259
|
+
* The class is intentionally a thin wrapper around a single Map cell — the
|
|
260
|
+
* cache key is `(organizationId)` since one client serves one org. Sharing
|
|
261
|
+
* one client across orgs would require per-org cache keys; we don't do
|
|
262
|
+
* that today because the dev-server pod is single-tenant.
|
|
62
263
|
*/
|
|
63
|
-
export
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
264
|
+
export class NpmRegistryClient {
|
|
265
|
+
deps;
|
|
266
|
+
/**
|
|
267
|
+
* Last successful fetch + when it landed. Source of both the TTL check
|
|
268
|
+
* and the last-known-good fallback. We deliberately store the entries as
|
|
269
|
+
* received (not the rendered `NpmRegistryConfig`) so future readers can
|
|
270
|
+
* apply different render policies without a re-fetch.
|
|
271
|
+
*
|
|
272
|
+
* `npmAllowInstallScripts` rides alongside because the server returns it
|
|
273
|
+
* on the same envelope and AppShell wants the same TTL/fallback semantics
|
|
274
|
+
* for both. `undefined` here means the server omitted the field (LD flag
|
|
275
|
+
* off on the server side, or older server); we propagate that as
|
|
276
|
+
* `allowInstallScripts: undefined` rather than defaulting to `true` so
|
|
277
|
+
* the consumer can distinguish "policy known, scripts allowed" from
|
|
278
|
+
* "policy unknown".
|
|
279
|
+
*/
|
|
280
|
+
cache;
|
|
281
|
+
/**
|
|
282
|
+
* Inflight `fetchWithRefresh()` promise, if any. Used to deduplicate
|
|
283
|
+
* concurrent `getConfig()` callers during a cache miss (or stale-after-
|
|
284
|
+
* TTL re-fetch): rather than firing N network requests for N callers
|
|
285
|
+
* we share the single in-flight request. Cleared in a `.finally()` so a
|
|
286
|
+
* resolved or rejected fetch never sticks around.
|
|
287
|
+
*
|
|
288
|
+
* Without this, a burst of installs that all hit cold cache would race
|
|
289
|
+
* to the server, the first one's 401 would clear the cache for itself
|
|
290
|
+
* before the others observe their own 401s — and the others would
|
|
291
|
+
* then have no last-known-good to fall back to.
|
|
292
|
+
*/
|
|
293
|
+
inflight;
|
|
294
|
+
/**
|
|
295
|
+
* Last `npmAllowInstallScripts` value observed from a successful server
|
|
296
|
+
* response, retained across hard-fail cache invalidations (refreshJwt
|
|
297
|
+
* rejection, double-401, other 4xx). Registry credentials and the
|
|
298
|
+
* security policy live on the same envelope but have different
|
|
299
|
+
* security semantics: serving stale credentials to a revoked caller
|
|
300
|
+
* extends a deliberate denial, but dropping a known `false` policy
|
|
301
|
+
* silently weakens `--ignore-scripts` enforcement — strictly the
|
|
302
|
+
* wrong direction. So `this.cache` is cleared on hard-fail (revoke
|
|
303
|
+
* stale credentials), but this field survives so the next call's
|
|
304
|
+
* fall-through `serveStaleOrUnconfigured` still propagates the
|
|
305
|
+
* org's policy to AppShell.
|
|
306
|
+
*/
|
|
307
|
+
lastKnownPolicy;
|
|
308
|
+
constructor(deps) {
|
|
309
|
+
this.deps = {
|
|
310
|
+
isFlagEnabled: deps.isFlagEnabled,
|
|
311
|
+
getJwt: deps.getJwt,
|
|
312
|
+
refreshJwt: deps.refreshJwt,
|
|
313
|
+
organizationId: deps.organizationId,
|
|
314
|
+
baseUrl: deps.baseUrl,
|
|
315
|
+
fetch: deps.fetch ?? globalThis.fetch,
|
|
316
|
+
ttlMs: deps.ttlMs ?? DEFAULT_TTL_MS,
|
|
317
|
+
now: deps.now ?? Date.now,
|
|
318
|
+
lastKnownPolicyStore: deps.lastKnownPolicyStore,
|
|
319
|
+
};
|
|
77
320
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Clears the in-memory cache so the next `getConfig()` re-fetches from
|
|
323
|
+
* the server. Called by AppShell install retry when
|
|
324
|
+
* `registry_auth_failed` indicates a stale Artifactory token.
|
|
325
|
+
*
|
|
326
|
+
* `lastKnownPolicy` survives invalidation (same as the auth-failure
|
|
327
|
+
* branches in `fetchWithRefresh`) so `--ignore-scripts` enforcement
|
|
328
|
+
* is not weakened during the re-fetch.
|
|
329
|
+
*/
|
|
330
|
+
invalidateCache() {
|
|
331
|
+
this.cache = undefined;
|
|
81
332
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
333
|
+
/**
|
|
334
|
+
* Persist the "ever configured" marker, AWAITED so the write is durable
|
|
335
|
+
* before the caller hands back a `configured` / `stale` resolution. Used
|
|
336
|
+
* on every such resolution so a subsequent server outage can fail closed
|
|
337
|
+
* instead of silently resolving through public npm.
|
|
338
|
+
*
|
|
339
|
+
* Awaited (not fire-and-forget) because the marker must be on disk before
|
|
340
|
+
* `getConfig()` returns: a crash in the gap between returning `configured`
|
|
341
|
+
* and an un-awaited write landing would lose the marker and silently break
|
|
342
|
+
* the "once configured, always fail-closed on unreachable" invariant. The
|
|
343
|
+
* write stays async (`fs/promises`, per the ai-service no-sync-fs rule);
|
|
344
|
+
* the disk store guards itself so the write happens at most once per
|
|
345
|
+
* process (see `createDiskRegistryPolicyStore`), keeping the awaited write
|
|
346
|
+
* off the hot path after the first configured resolution.
|
|
347
|
+
*
|
|
348
|
+
* Logs a warning on write failure but never propagates the error: a broken
|
|
349
|
+
* store is a strict superset of today's no-marker behaviour, and the
|
|
350
|
+
* install path must stay healthy when disk is full or the marker directory
|
|
351
|
+
* is unwritable.
|
|
352
|
+
*/
|
|
353
|
+
async markPolicyConfigured() {
|
|
354
|
+
const store = this.deps.lastKnownPolicyStore;
|
|
355
|
+
if (!store) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
await store.markConfigured();
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
getLogger().warn("[npm-registry] lastKnownPolicyStore.markConfigured() failed; ignoring", {
|
|
363
|
+
orgId: this.deps.organizationId,
|
|
364
|
+
error: err instanceof Error ? err.message : String(err),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
90
367
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
368
|
+
/**
|
|
369
|
+
* Read the disk-backed marker that AppShell uses to decide whether to
|
|
370
|
+
* fail closed on `unreachable`. Treats a read failure as `false` so a
|
|
371
|
+
* transient store IO error preserves current behaviour (proceed) rather
|
|
372
|
+
* than degrading to a fail-closed install.
|
|
373
|
+
*/
|
|
374
|
+
async readEverConfigured() {
|
|
375
|
+
const store = this.deps.lastKnownPolicyStore;
|
|
376
|
+
if (!store) {
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
return await store.hasEverBeenConfigured();
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
getLogger().warn("[npm-registry] lastKnownPolicyStore.hasEverBeenConfigured() failed; treating as false", {
|
|
384
|
+
orgId: this.deps.organizationId,
|
|
385
|
+
error: err instanceof Error ? err.message : String(err),
|
|
386
|
+
});
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
95
389
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
390
|
+
/**
|
|
391
|
+
* Resolve the current npm registry configuration for the active org. See
|
|
392
|
+
* the class docstring for the full state machine.
|
|
393
|
+
*/
|
|
394
|
+
async getConfig() {
|
|
395
|
+
if (!this.deps.isFlagEnabled()) {
|
|
396
|
+
// Short-circuit BEFORE inspecting the cache: a flag flip to "off"
|
|
397
|
+
// must not keep serving from a previously-fetched config. The
|
|
398
|
+
// ticket explicitly calls this out ("Flag off → return
|
|
399
|
+
// {configured: false} immediately, no server call").
|
|
400
|
+
return NOT_CONFIGURED;
|
|
401
|
+
}
|
|
402
|
+
const now = this.deps.now();
|
|
403
|
+
if (this.cache && now - this.cache.fetchedAt < this.deps.ttlMs) {
|
|
404
|
+
// A cached empty list (from a prior 404 or `registries: []` response)
|
|
405
|
+
// must surface as `not-configured` on every hit within the TTL, not
|
|
406
|
+
// `configured` with `config.configured: false`. The discriminator's
|
|
407
|
+
// "configured" branch is documented as "at least one registry row
|
|
408
|
+
// exists" — preserving that invariant matters for callers branching
|
|
409
|
+
// on `source` for observability. We still surface any cached policy
|
|
410
|
+
// alongside the not-configured result so AppShell can honor
|
|
411
|
+
// `--ignore-scripts` even when there are no registry rows.
|
|
412
|
+
if (this.cache.entries.length === 0) {
|
|
413
|
+
return notConfigured(this.cache.npmAllowInstallScripts);
|
|
414
|
+
}
|
|
415
|
+
await this.markPolicyConfigured();
|
|
416
|
+
return {
|
|
417
|
+
source: "configured",
|
|
418
|
+
config: registriesToConfig(this.cache.entries, this.cache.npmAllowInstallScripts),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
// Deduplicate concurrent cache-miss callers onto a single network
|
|
422
|
+
// request so we don't multiply 401-refresh storms or lose last-known-
|
|
423
|
+
// good fallback for racing callers.
|
|
424
|
+
if (this.inflight) {
|
|
425
|
+
return this.inflight;
|
|
426
|
+
}
|
|
427
|
+
const pending = this.fetchWithRefresh().finally(() => {
|
|
428
|
+
// Always clear inflight on resolve OR reject so a transient error
|
|
429
|
+
// doesn't pin the slot and starve subsequent callers.
|
|
430
|
+
if (this.inflight === pending) {
|
|
431
|
+
this.inflight = undefined;
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
this.inflight = pending;
|
|
435
|
+
return pending;
|
|
436
|
+
}
|
|
437
|
+
async fetchWithRefresh() {
|
|
438
|
+
let response;
|
|
100
439
|
try {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
440
|
+
response = await this.attempt();
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
return this.serveStaleOrUnconfigured(err);
|
|
444
|
+
}
|
|
445
|
+
if (response.status === 401) {
|
|
446
|
+
// Do NOT clear `this.cache` here: if the retry fails,
|
|
447
|
+
// `serveStaleOrUnconfigured` still needs the last-known-good
|
|
448
|
+
// entries to fall back to. Concurrent callers are serialised on
|
|
449
|
+
// `this.inflight`, so they won't observe a stale value during the
|
|
450
|
+
// retry window.
|
|
451
|
+
//
|
|
452
|
+
// Await `refreshJwt` (if wired) so the retry has a fair chance
|
|
453
|
+
// of seeing a freshly-rotated token. A refresh-channel error
|
|
454
|
+
// combined with a 401 from the registry endpoint is ambiguous —
|
|
455
|
+
// it can be a transient outage OR a terminal revocation
|
|
456
|
+
// (refresh token rejected, account disabled). We cannot tell
|
|
457
|
+
// from out here, and serving last-known-good in the revocation
|
|
458
|
+
// case would let a revoked caller continue installing from a
|
|
459
|
+
// private registry until TTL expiry. Treat refresh failure as a
|
|
460
|
+
// hard fail: clear the cache and surface the error, mirroring
|
|
461
|
+
// the deliberate-denial 4xx branch below.
|
|
462
|
+
if (this.deps.refreshJwt) {
|
|
463
|
+
try {
|
|
464
|
+
await this.deps.refreshJwt();
|
|
121
465
|
}
|
|
122
|
-
|
|
123
|
-
|
|
466
|
+
catch (err) {
|
|
467
|
+
this.cache = undefined;
|
|
468
|
+
const refreshFailError = new Error(`[npm-registry] refreshJwt failed after HTTP 401 from the npm registry endpoint: ${err instanceof Error ? err.message : String(err)}`);
|
|
469
|
+
getLogger().error(`[npm-registry] hard-fail: refreshJwt rejected after HTTP 401 (org ${this.deps.organizationId}); cache cleared`, {
|
|
470
|
+
error: {
|
|
471
|
+
kind: "NpmRegistryRefreshFail",
|
|
472
|
+
message: refreshFailError.message,
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
throw refreshFailError;
|
|
124
476
|
}
|
|
125
477
|
}
|
|
478
|
+
try {
|
|
479
|
+
response = await this.attempt();
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
return this.serveStaleOrUnconfigured(err);
|
|
483
|
+
}
|
|
484
|
+
if (response.status === 401) {
|
|
485
|
+
// A second 401 after a successful refreshJwt is stronger evidence
|
|
486
|
+
// of revocation than either the refreshJwt-rejection branch above
|
|
487
|
+
// or the deliberate-denial 4xx branch below: the refresh channel
|
|
488
|
+
// worked, but the freshly-issued JWT is still rejected. Clear the
|
|
489
|
+
// cache so a subsequent 5xx/network error cannot fall back through
|
|
490
|
+
// `serveStaleOrUnconfigured` and serve last-known-good config to
|
|
491
|
+
// a potentially-revoked user until TTL expiry. We surface this
|
|
492
|
+
// rather than swallowing it because "silently fall back to public
|
|
493
|
+
// npm" is precisely the failure mode this whole feature exists
|
|
494
|
+
// to prevent.
|
|
495
|
+
this.cache = undefined;
|
|
496
|
+
const doubleAuthFailError = new Error("[npm-registry] re-authenticated and still received HTTP 401 from the npm registry endpoint");
|
|
497
|
+
getLogger().error(`[npm-registry] hard-fail: HTTP 401 persisted after refreshJwt (org ${this.deps.organizationId}); cache cleared`, {
|
|
498
|
+
error: {
|
|
499
|
+
kind: "NpmRegistryDoubleAuthFail",
|
|
500
|
+
message: doubleAuthFailError.message,
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
throw doubleAuthFailError;
|
|
504
|
+
}
|
|
126
505
|
}
|
|
127
|
-
|
|
128
|
-
|
|
506
|
+
if (response.status === 404) {
|
|
507
|
+
// The route exists but the org has no registry rows (or the
|
|
508
|
+
// sibling endpoint returned 404 for a transient reason — deploy
|
|
509
|
+
// window, route mis-mount, brief org-deactivation race). Treat
|
|
510
|
+
// the same as an empty list response — `{ configured: false }`.
|
|
511
|
+
// We still cache "empty" so we don't poll the 404 path on every
|
|
512
|
+
// install.
|
|
513
|
+
//
|
|
514
|
+
// We preserve any previously-observed `npmAllowInstallScripts`
|
|
515
|
+
// policy rather than overwriting it with `undefined`: a transient
|
|
516
|
+
// 404 against a freshly-deployed server (or an envoy mis-route)
|
|
517
|
+
// would otherwise silently disable an org's `--ignore-scripts`
|
|
518
|
+
// enforcement for the rest of the TTL window. The 404 payload
|
|
519
|
+
// doesn't carry a fresh policy, but a previously-observed policy
|
|
520
|
+
// is the best estimate we have until the next successful fetch.
|
|
521
|
+
//
|
|
522
|
+
// Fall back to `lastKnownPolicy` when `this.cache` itself was
|
|
523
|
+
// hard-cleared by an earlier auth-failure branch — the policy
|
|
524
|
+
// survives independently of the registry credentials it rode in
|
|
525
|
+
// with, and reading from cache alone would clobber it back to
|
|
526
|
+
// `undefined` here.
|
|
527
|
+
const preservedPolicy = this.cache?.npmAllowInstallScripts ?? this.lastKnownPolicy;
|
|
528
|
+
this.cache = {
|
|
529
|
+
entries: [],
|
|
530
|
+
npmAllowInstallScripts: preservedPolicy,
|
|
531
|
+
fetchedAt: this.deps.now(),
|
|
532
|
+
};
|
|
533
|
+
this.lastKnownPolicy = preservedPolicy;
|
|
534
|
+
return notConfigured(preservedPolicy);
|
|
535
|
+
}
|
|
536
|
+
if (response.status >= 400 &&
|
|
537
|
+
response.status < 500 &&
|
|
538
|
+
response.status !== 401 &&
|
|
539
|
+
response.status !== 404) {
|
|
540
|
+
// Other 4xx ("the request is bad" or "you specifically can't do
|
|
541
|
+
// this") are deliberate denials, not transient outages. Examples:
|
|
542
|
+
// - 403 after org RBAC permission revocation
|
|
543
|
+
// - 400 from a malformed Authorization header
|
|
544
|
+
// Serving stale here would let a revoked-access install run with
|
|
545
|
+
// a cached registry token until TTL expiry — exactly the staleness
|
|
546
|
+
// window we want to avoid. Clear the cache and surface the error
|
|
547
|
+
// so the caller decides; the inflight slot is cleared by the
|
|
548
|
+
// outer `.finally()` so a subsequent call can re-attempt.
|
|
549
|
+
this.cache = undefined;
|
|
550
|
+
const hardFailError = new Error(`[npm-registry] server returned HTTP ${response.status} from the npm registry endpoint`);
|
|
551
|
+
getLogger().error(`[npm-registry] hard-fail HTTP ${response.status} from npm registry endpoint (org ${this.deps.organizationId}); cache cleared`, {
|
|
552
|
+
error: {
|
|
553
|
+
kind: "NpmRegistryHardFail",
|
|
554
|
+
message: hardFailError.message,
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
throw hardFailError;
|
|
558
|
+
}
|
|
559
|
+
if (!response.ok) {
|
|
560
|
+
// 5xx is treated as a transient outage. Last-known-good if we
|
|
561
|
+
// have one, otherwise `not-configured`.
|
|
562
|
+
return this.serveStaleOrUnconfigured(new Error(`[npm-registry] server returned HTTP ${response.status} from the npm registry endpoint`));
|
|
563
|
+
}
|
|
564
|
+
// Read the body AND unwrap the standard Superblocks `ResponseDto`
|
|
565
|
+
// envelope (`{ data, responseMeta }`) the server wraps every JSON
|
|
566
|
+
// response in. The UI client uses `callServer` (which delegates to
|
|
567
|
+
// `unwrapResponseDto` in `@superblocksteam/shared`) for the same shape;
|
|
568
|
+
// here we keep raw `fetch` (preserves the DI surface the test harness
|
|
569
|
+
// mocks) and unwrap inline so the registry-payload schema below mirrors
|
|
570
|
+
// the inner shape, not the wire envelope. JSON-parse failures and
|
|
571
|
+
// envelope-shape failures both route to `serveStale…` — single try.
|
|
572
|
+
let payload;
|
|
573
|
+
try {
|
|
574
|
+
const body = await response.json();
|
|
575
|
+
const envelopeParse = envelopeSchema.safeParse(body);
|
|
576
|
+
if (!envelopeParse.success) {
|
|
577
|
+
const envelopeError = new Error(`[npm-registry] server returned a payload that did not match the expected ResponseDto envelope (org ${this.deps.organizationId}): ${JSON.stringify(envelopeParse.error.issues)}`);
|
|
578
|
+
getLogger().error(envelopeError.message, {
|
|
579
|
+
error: {
|
|
580
|
+
kind: "NpmRegistryEnvelopeParseFailed",
|
|
581
|
+
message: envelopeError.message,
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
throw envelopeError;
|
|
585
|
+
}
|
|
586
|
+
payload = envelopeParse.data.data;
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
return this.serveStaleOrUnconfigured(err);
|
|
590
|
+
}
|
|
591
|
+
const parsed = registryListSchema.safeParse(payload);
|
|
592
|
+
if (!parsed.success) {
|
|
593
|
+
// Log schema issues at error level (distinct from the generic
|
|
594
|
+
// serveStale warn) so a malformed payload is visible in the absence
|
|
595
|
+
// of a network outage — most likely a server-side validation bug
|
|
596
|
+
// or a hostile push. The Zod issue list is folded into the message
|
|
597
|
+
// because the `error` logger only accepts `ErrorMeta` shape.
|
|
598
|
+
const schemaError = new Error(`[npm-registry] server returned a payload that did not match the expected { registries: [...] } shape (org ${this.deps.organizationId}): ${JSON.stringify(parsed.error.issues)}`);
|
|
599
|
+
getLogger().error(schemaError.message, {
|
|
600
|
+
error: {
|
|
601
|
+
kind: "NpmRegistrySchemaParseFailed",
|
|
602
|
+
message: schemaError.message,
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
return this.serveStaleOrUnconfigured(schemaError);
|
|
606
|
+
}
|
|
607
|
+
const entries = parsed.data.registries;
|
|
608
|
+
const npmAllowInstallScripts = parsed.data.npmAllowInstallScripts;
|
|
609
|
+
this.cache = {
|
|
610
|
+
entries,
|
|
611
|
+
npmAllowInstallScripts,
|
|
612
|
+
fetchedAt: this.deps.now(),
|
|
613
|
+
};
|
|
614
|
+
this.lastKnownPolicy = npmAllowInstallScripts;
|
|
615
|
+
if (entries.length === 0) {
|
|
616
|
+
return notConfigured(npmAllowInstallScripts);
|
|
617
|
+
}
|
|
618
|
+
await this.markPolicyConfigured();
|
|
619
|
+
return {
|
|
620
|
+
source: "configured",
|
|
621
|
+
config: registriesToConfig(entries, npmAllowInstallScripts),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* One full fetch attempt with the current JWT. Throws on getJwt failure
|
|
626
|
+
* OR network failure so the caller's single try/catch handles both.
|
|
627
|
+
*/
|
|
628
|
+
async attempt() {
|
|
629
|
+
const jwt = await this.deps.getJwt();
|
|
630
|
+
const base = this.deps.baseUrl.replace(/\/$/, "");
|
|
631
|
+
const url = `${base}/api/v1/organizations/${encodeURIComponent(this.deps.organizationId)}/npm-registry`;
|
|
632
|
+
return await this.deps.fetch(url, {
|
|
633
|
+
method: "GET",
|
|
634
|
+
headers: {
|
|
635
|
+
accept: "application/json",
|
|
636
|
+
authorization: `Bearer ${jwt}`,
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
async serveStaleOrUnconfigured(error) {
|
|
641
|
+
if (this.cache && this.cache.entries.length > 0) {
|
|
642
|
+
const ageMs = this.deps.now() - this.cache.fetchedAt;
|
|
643
|
+
getLogger().warn("[npm-registry] server unreachable; serving last-known-good config", {
|
|
644
|
+
ageMs,
|
|
645
|
+
error: error instanceof Error ? error.message : String(error),
|
|
646
|
+
});
|
|
647
|
+
// APPS-4370: stale serves a known-configured org, so refresh the
|
|
648
|
+
// disk marker too. A long outage that spans a pod recycle would
|
|
649
|
+
// otherwise lose the signal: the next cold-boot would see `unreachable`
|
|
650
|
+
// with no marker and proceed instead of failing closed.
|
|
651
|
+
await this.markPolicyConfigured();
|
|
652
|
+
return {
|
|
653
|
+
source: "stale",
|
|
654
|
+
config: registriesToConfig(this.cache.entries, this.cache.npmAllowInstallScripts),
|
|
655
|
+
};
|
|
129
656
|
}
|
|
657
|
+
// No usable cache: either nothing was ever fetched successfully (cold
|
|
658
|
+
// start) or the only successful fetch returned an empty list. Both
|
|
659
|
+
// mean we don't actually know the org's current registry state, so
|
|
660
|
+
// we surface `unreachable` rather than `not-configured`. Consumers
|
|
661
|
+
// with destructive cleanup paths (e.g. the home-npmrc writer
|
|
662
|
+
// unlinking a previously-written `~/.npmrc`) need that distinction to
|
|
663
|
+
// avoid acting on a transient outage; read-only consumers branch on
|
|
664
|
+
// `config.configured` and continue to no-op safely either way.
|
|
665
|
+
//
|
|
666
|
+
// A cached `npmAllowInstallScripts` policy IS useful and survives the
|
|
667
|
+
// outage: AppShell still honors a known `--ignore-scripts` policy
|
|
668
|
+
// when the server is down. Falls back to `lastKnownPolicy` when
|
|
669
|
+
// `this.cache` itself was hard-cleared by an auth-failure branch
|
|
670
|
+
// above — the policy survives independently of the registry
|
|
671
|
+
// credentials it rode in with.
|
|
672
|
+
getLogger().warn("[npm-registry] server unreachable and no usable cached config; treating as unreachable", { error: error instanceof Error ? error.message : String(error) });
|
|
673
|
+
const base = unreachable(this.cache?.npmAllowInstallScripts ?? this.lastKnownPolicy);
|
|
674
|
+
// APPS-4370: consult the disk-backed marker so AppShell can decide
|
|
675
|
+
// whether to fail closed. Only attach the field when a store is
|
|
676
|
+
// wired in — `undefined` is the documented "no opinion" signal.
|
|
677
|
+
const everConfigured = await this.readEverConfigured();
|
|
678
|
+
if (everConfigured === undefined) {
|
|
679
|
+
return base;
|
|
680
|
+
}
|
|
681
|
+
// `base` may be the frozen UNREACHABLE singleton; build a fresh
|
|
682
|
+
// object so adding `everConfigured` doesn't try to mutate a frozen
|
|
683
|
+
// value (TypeError in strict mode).
|
|
684
|
+
return Object.freeze({
|
|
685
|
+
source: "unreachable",
|
|
686
|
+
config: base.config,
|
|
687
|
+
everConfigured,
|
|
688
|
+
});
|
|
130
689
|
}
|
|
131
|
-
return { url, token, scopes, require };
|
|
132
690
|
}
|
|
133
691
|
/**
|
|
134
692
|
* Extracts the host portion of a registry URL for use in npm auth lines, e.g.
|
|
@@ -165,28 +723,68 @@ function normalizeRegistryUrl(registryUrl) {
|
|
|
165
723
|
/**
|
|
166
724
|
* Renders the npm registry config to an `.npmrc` file at `targetPath`.
|
|
167
725
|
*
|
|
726
|
+
* Uses `ini.stringify` (the same library `@npmcli/config` uses to read and
|
|
727
|
+
* write `.npmrc`) so the file is guaranteed to round-trip through
|
|
728
|
+
* `ini.parse`. This handles value-quoting for `=`, `\r`, `\n`, leading `[`,
|
|
729
|
+
* leading/trailing whitespace, and escapes `;` / `#` so they're not treated
|
|
730
|
+
* as comments.
|
|
731
|
+
*
|
|
732
|
+
* Validation NOT covered by `ini`:
|
|
733
|
+
* - `noControlChars` Zod refinement: `ini.safe` JSON-quotes `\r`/`\n`
|
|
734
|
+
* rather than rejecting them, and accepts other C0 control bytes
|
|
735
|
+
* (`\x00..\x1f`) unchanged — so we reject the entire C0 range at the
|
|
736
|
+
* wire-schema layer instead.
|
|
737
|
+
* - Scope grammar (`@scope` must match npm's regex): `@npmcli/config`
|
|
738
|
+
* does not validate scope names.
|
|
739
|
+
* - https-only registry URLs: npm itself accepts http.
|
|
740
|
+
*
|
|
168
741
|
* When `preserveScopeLines` is provided, any existing `<scope>:registry=`
|
|
169
|
-
*
|
|
742
|
+
* keys for those scopes are preserved (so EE-baked
|
|
170
743
|
* `@superblocksteam:registry=https://npm.pkg.github.com/` survives) UNLESS
|
|
171
|
-
* the same scope is explicitly overridden in `config.scopes`.
|
|
744
|
+
* the same scope is explicitly overridden in `config.scopes`. The
|
|
745
|
+
* preserved value is read via `ini.parse` from the existing file, so
|
|
746
|
+
* `ini`'s own quoting rules apply (rather than our older line-grep that
|
|
747
|
+
* could mis-handle quoted values).
|
|
172
748
|
*
|
|
173
749
|
* Idempotent: rewrites the file in full each call. Writes are atomic (tmp +
|
|
174
750
|
* rename) and the file mode is restricted to 0600 because the file may carry
|
|
175
751
|
* an auth token.
|
|
752
|
+
*
|
|
753
|
+
* Requires `config.configured === true` OR `config.allowInstallScripts === false`
|
|
754
|
+
* (so the writer can emit a policy-only `.npmrc` with just `ignore-scripts=true`
|
|
755
|
+
* even when no registry rows are configured). A scoped-only config (no `default`
|
|
756
|
+
* entry) is valid — only scope `:registry=` lines are emitted, and unscoped
|
|
757
|
+
* packages fall through to public npm. This matches the server contract
|
|
758
|
+
* (the `(org, scope)` unique constraint allows scoped-only rows) and is
|
|
759
|
+
* the right shape for customers who only want to override one `@scope`
|
|
760
|
+
* without redirecting the rest of npm.
|
|
176
761
|
*/
|
|
177
762
|
export async function writeNpmrc(targetPath, config, options = {}) {
|
|
763
|
+
const needsIgnoreScripts = config.allowInstallScripts === false;
|
|
764
|
+
if (!config.configured && !needsIgnoreScripts) {
|
|
765
|
+
throw new Error("[npm-registry] writeNpmrc requires a configured config");
|
|
766
|
+
}
|
|
767
|
+
const hasDefault = !!config.default;
|
|
768
|
+
const hasScopes = !!config.scopes && Object.keys(config.scopes).length > 0;
|
|
769
|
+
if (!hasDefault && !hasScopes && !needsIgnoreScripts) {
|
|
770
|
+
throw new Error("[npm-registry] writeNpmrc requires at least one registry entry (default or scoped)");
|
|
771
|
+
}
|
|
772
|
+
// Flat key -> value map. `.npmrc` uses no `ini` sections at the registry
|
|
773
|
+
// layer (all keys are top-level), so `ini.stringify` on a flat object
|
|
774
|
+
// produces the exact wire format npm expects.
|
|
775
|
+
const obj = {};
|
|
178
776
|
const preserveScopes = options.preserveScopeLines ?? [];
|
|
179
|
-
// Map of scope -> verbatim line, so we both dedup correctly and preserve the
|
|
180
|
-
// exact bytes (whitespace, trailing comments) the operator wrote.
|
|
181
|
-
const preservedScopeLines = new Map();
|
|
182
777
|
if (preserveScopes.length > 0) {
|
|
183
|
-
let existing
|
|
778
|
+
let existing;
|
|
184
779
|
try {
|
|
185
780
|
existing = await readFile(targetPath, "utf-8");
|
|
186
781
|
}
|
|
187
782
|
catch (error) {
|
|
188
|
-
// ENOENT is the expected case
|
|
189
|
-
//
|
|
783
|
+
// ENOENT is the expected case on first write — every other code
|
|
784
|
+
// (EACCES, EISDIR, EIO) we surface as a warning rather than throw
|
|
785
|
+
// because preserve-scope is a best-effort fallback and we'd rather
|
|
786
|
+
// emit a fresh `.npmrc` than fail the whole install path. The
|
|
787
|
+
// tradeoff: a transient read error could silently lose a baked
|
|
190
788
|
// `@superblocksteam:registry=` line (the exact failure class APPS-2053
|
|
191
789
|
// was meant to prevent).
|
|
192
790
|
const code = error?.code;
|
|
@@ -199,80 +797,1121 @@ export async function writeNpmrc(targetPath, config, options = {}) {
|
|
|
199
797
|
}
|
|
200
798
|
}
|
|
201
799
|
if (existing) {
|
|
202
|
-
|
|
800
|
+
// `ini.parse` skips lines it cannot decode rather than throwing
|
|
801
|
+
// (CRLF artifacts, partial writes, hand-edits with stray bytes —
|
|
802
|
+
// see ini@6 `decode`'s regex-or-continue loop). Guard with try/catch
|
|
803
|
+
// for future versions that may throw, and treat any unrecognised
|
|
804
|
+
// result as "no preserved value" — but warn so an operator can repair
|
|
805
|
+
// a malformed `.npmrc` instead of silently shipping without the
|
|
806
|
+
// EE-baked `@superblocksteam:registry=` line.
|
|
807
|
+
let parsedExisting = {};
|
|
808
|
+
try {
|
|
809
|
+
parsedExisting = ini.parse(existing);
|
|
810
|
+
}
|
|
811
|
+
catch (err) {
|
|
812
|
+
getLogger().warn("[npm-registry] failed to parse existing .npmrc for scope preservation; proceeding without preserve", {
|
|
813
|
+
path: targetPath,
|
|
814
|
+
error: err instanceof Error ? err.message : String(err),
|
|
815
|
+
});
|
|
816
|
+
}
|
|
203
817
|
for (const scope of preserveScopes) {
|
|
204
|
-
// Skip preservation if config explicitly overrides this scope
|
|
818
|
+
// Skip preservation if config explicitly overrides this scope —
|
|
819
|
+
// the override will be written below and we don't want to emit
|
|
820
|
+
// two `<scope>:registry=` lines (or have the preserved value
|
|
821
|
+
// shadow the explicit one). The override-wins symmetry also
|
|
822
|
+
// applies to the auth-family lines for the old origin: dropping
|
|
823
|
+
// the registry line while keeping a `//old-origin/:_authToken=`
|
|
824
|
+
// around would leave a dangling secret pointed at no registry
|
|
825
|
+
// entry in the rendered file.
|
|
205
826
|
if (config.scopes && config.scopes[scope]) {
|
|
206
827
|
continue;
|
|
207
828
|
}
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
829
|
+
const key = `${scope}:registry`;
|
|
830
|
+
const value = parsedExisting[key];
|
|
831
|
+
if (typeof value === "string") {
|
|
832
|
+
obj[key] = value;
|
|
833
|
+
// Also preserve every auth-family key keyed on the same
|
|
834
|
+
// `//<origin>/` prefix as the preserved registry URL. Without
|
|
835
|
+
// this, EE-baked `.npmrc` files that ship both
|
|
836
|
+
// `@superblocksteam:registry=https://npm.pkg.github.com/` AND
|
|
837
|
+
// `//npm.pkg.github.com/:_authToken=<ghpr-token>` lose the
|
|
838
|
+
// token on the first `writeNpmrc` call, and every
|
|
839
|
+
// `@superblocksteam/*` install 401s against GHPR (APPS-4300).
|
|
840
|
+
//
|
|
841
|
+
// npm/pnpm support multiple auth shapes against the same
|
|
842
|
+
// origin: bearer (`_authToken`), Basic (`_password` +
|
|
843
|
+
// `username` + `email`), and the legacy base64 (`_auth`).
|
|
844
|
+
// We preserve all of them so a future EE image change that
|
|
845
|
+
// swaps from bearer to Basic doesn't silently regress to the
|
|
846
|
+
// same failure mode.
|
|
847
|
+
//
|
|
848
|
+
// `always-auth` is deliberately NOT preserved: npm removed it
|
|
849
|
+
// as a recognized key in npm 7+, and npm 11 (the dev-server's
|
|
850
|
+
// pinned version) emits `npm warn Unknown user config
|
|
851
|
+
// "always-auth" … This will stop working in the next major
|
|
852
|
+
// version of npm` on every invocation that reads it — auth
|
|
853
|
+
// still attaches from the sibling `_authToken`, so carrying it
|
|
854
|
+
// forward is a no-op that only generates stderr noise today and
|
|
855
|
+
// risks a hard error once we move to npm 12. Dropping it from
|
|
856
|
+
// the preserve set means a customer `.npmrc` that happens to
|
|
857
|
+
// carry `//origin/:always-auth` simply loses a dead key (APPS-4430).
|
|
858
|
+
//
|
|
859
|
+
// Each `parsedExisting[copyKey]` returns the same `unknown`
|
|
860
|
+
// shape as the registry line above; only string values are
|
|
861
|
+
// safe to round-trip back through `ini.stringify`. Non-string
|
|
862
|
+
// values follow the same drop+warn handling.
|
|
863
|
+
const preservedAuthOrigin = toNpmAuthOrigin(value);
|
|
864
|
+
if (preservedAuthOrigin) {
|
|
865
|
+
const authFamilyKeys = [
|
|
866
|
+
"_authToken",
|
|
867
|
+
"_password",
|
|
868
|
+
"username",
|
|
869
|
+
"email",
|
|
870
|
+
"_auth",
|
|
871
|
+
];
|
|
872
|
+
for (const authKey of authFamilyKeys) {
|
|
873
|
+
const copyKey = `${preservedAuthOrigin}:${authKey}`;
|
|
874
|
+
const authValue = parsedExisting[copyKey];
|
|
875
|
+
// `ini.parse` coerces a bare `true`/`false`/numeric value to
|
|
876
|
+
// its JS primitive equivalent. The auth-family keys above are
|
|
877
|
+
// all string-valued, but coerce back to a string defensively
|
|
878
|
+
// so `ini.stringify` emits a valid line (it accepts strings,
|
|
879
|
+
// booleans, and numbers, but our internal `obj` map is typed
|
|
880
|
+
// `Record<string, string>` for the registry/auth lines
|
|
881
|
+
// elsewhere in this writer, so the cast is contained).
|
|
882
|
+
if (typeof authValue === "string" ||
|
|
883
|
+
typeof authValue === "boolean" ||
|
|
884
|
+
typeof authValue === "number") {
|
|
885
|
+
obj[copyKey] = String(authValue);
|
|
886
|
+
}
|
|
887
|
+
else if (authValue !== undefined) {
|
|
888
|
+
getLogger().warn("[npm-registry] preserve-scope auth-family key parsed to unsupported type; dropping", {
|
|
889
|
+
path: targetPath,
|
|
890
|
+
scope,
|
|
891
|
+
authKey,
|
|
892
|
+
valueType: typeof authValue,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
}
|
|
214
896
|
}
|
|
215
897
|
}
|
|
898
|
+
else if (value !== undefined) {
|
|
899
|
+
// Key parsed but value is not a string — e.g. a bare key (`true`),
|
|
900
|
+
// a section header above it, or a quoted value whose `JSON.parse`
|
|
901
|
+
// failed inside `ini`. We're about to silently drop the preserve
|
|
902
|
+
// target, so surface so the operator can repair the file.
|
|
903
|
+
getLogger().warn("[npm-registry] preserve-scope key parsed to non-string; dropping", {
|
|
904
|
+
path: targetPath,
|
|
905
|
+
scope,
|
|
906
|
+
valueType: typeof value,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
// value === undefined is the normal case when the existing .npmrc
|
|
910
|
+
// simply doesn't list this scope — silent on that.
|
|
216
911
|
}
|
|
217
912
|
}
|
|
218
913
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
914
|
+
if (config.default) {
|
|
915
|
+
const normalizedDefaultUrl = normalizeRegistryUrl(config.default.url);
|
|
916
|
+
obj["registry"] = normalizedDefaultUrl;
|
|
917
|
+
const defaultAuthOrigin = toNpmAuthOrigin(normalizedDefaultUrl);
|
|
918
|
+
if (config.default.token && defaultAuthOrigin) {
|
|
919
|
+
obj[`${defaultAuthOrigin}:_authToken`] = config.default.token;
|
|
920
|
+
}
|
|
225
921
|
}
|
|
226
922
|
if (config.scopes) {
|
|
227
|
-
for (const [scope,
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
923
|
+
for (const [scope, scopedReg] of Object.entries(config.scopes)) {
|
|
924
|
+
const normalizedScopeUrl = normalizeRegistryUrl(scopedReg.url);
|
|
925
|
+
obj[`${scope}:registry`] = normalizedScopeUrl;
|
|
926
|
+
const scopeAuthOrigin = toNpmAuthOrigin(normalizedScopeUrl);
|
|
927
|
+
if (scopedReg.token && scopeAuthOrigin) {
|
|
928
|
+
obj[`${scopeAuthOrigin}:_authToken`] = scopedReg.token;
|
|
929
|
+
}
|
|
232
930
|
}
|
|
233
931
|
}
|
|
234
|
-
|
|
235
|
-
|
|
932
|
+
// Bake the org's ignore-scripts policy into the rendered `.npmrc` so any
|
|
933
|
+
// npm/pnpm invocation in this directory (or `$HOME`) honours the policy
|
|
934
|
+
// regardless of whether it was launched by AppShell. Covers tools like
|
|
935
|
+
// patch-package, husky, simple-git-hooks, and ad-hoc `bash npm install`.
|
|
936
|
+
if (needsIgnoreScripts) {
|
|
937
|
+
obj["ignore-scripts"] = "true";
|
|
938
|
+
}
|
|
939
|
+
// `ini.stringify` returns a trailing-newline-terminated string. Trim and
|
|
940
|
+
// re-add a single `\n` so the file is normalised regardless of the
|
|
941
|
+
// version of `ini`.
|
|
942
|
+
const body = `${ini.stringify(obj).trim()}\n`;
|
|
943
|
+
let content = body;
|
|
944
|
+
if (options.header !== undefined) {
|
|
945
|
+
if (/[\r\n]/.test(options.header)) {
|
|
946
|
+
throw new Error("[npm-registry] writeNpmrc options.header must not contain newlines");
|
|
947
|
+
}
|
|
948
|
+
content = `; ${options.header}\n${body}`;
|
|
236
949
|
}
|
|
237
|
-
const
|
|
950
|
+
const mode = options.mode ?? 0o600;
|
|
238
951
|
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
239
952
|
// Atomic write: render to a sibling tmp file with restrictive perms, then
|
|
240
953
|
// rename. Avoids leaving a partial/empty .npmrc on crash mid-write, and
|
|
241
954
|
// narrows the window where the token might be readable at the default umask.
|
|
955
|
+
// The atomic rename replaces the target file's inode regardless of the
|
|
956
|
+
// target's current mode (rename is governed by parent-dir perms), so callers
|
|
957
|
+
// that pass a write-stripped `mode` (e.g. 0o400) can still rewrite the file
|
|
958
|
+
// on subsequent syncs.
|
|
959
|
+
//
|
|
960
|
+
// We write the tmp file at 0o600 (owner-writable) first so any internal
|
|
961
|
+
// mode-restricting steps work, then chmod to the requested `mode` right
|
|
962
|
+
// before the rename so the final file lands with the requested bits set
|
|
963
|
+
// atomically with the contents.
|
|
242
964
|
const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
965
|
+
try {
|
|
966
|
+
await writeFile(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
967
|
+
// `mode` only applies on creation; explicit chmod handles the case where the
|
|
968
|
+
// tmp path was preexisting (e.g. crash recovery).
|
|
969
|
+
await chmod(tmpPath, mode);
|
|
970
|
+
await rename(tmpPath, targetPath);
|
|
971
|
+
}
|
|
972
|
+
catch (error) {
|
|
973
|
+
// Clean up the tmp file on any failure of write/chmod/rename so we
|
|
974
|
+
// don't leave `.npmrc.tmp-<pid>-<ts>` siblings accumulating across
|
|
975
|
+
// boots (especially in `~/` for the home writer). Best-effort: if the
|
|
976
|
+
// tmp file was never created (writeFile threw) or was already
|
|
977
|
+
// consumed by the rename, the unlink is a no-op we swallow.
|
|
978
|
+
await unlink(tmpPath).catch(() => undefined);
|
|
979
|
+
throw error;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Filename of the on-disk backup of the baked-in `.npmrc` that ships with the
|
|
984
|
+
* dev-server image. Captured once per pod boot by `maybeWriteNpmrcForDir`
|
|
985
|
+
* before the first `writeNpmrc` rewrite, then used as the restore source when
|
|
986
|
+
* the org transitions back to `not-configured` (e.g. the last
|
|
987
|
+
* `org_npm_registry` row was deleted). Lives sibling to `.npmrc` in the same
|
|
988
|
+
* directory writes target.
|
|
989
|
+
*/
|
|
990
|
+
export const NPMRC_DEFAULT_FILENAME = ".npmrc.default";
|
|
991
|
+
/**
|
|
992
|
+
* Snapshot `targetPath` to `backupPath` exactly once if the backup does
|
|
993
|
+
* not already exist. Lets a subsequent `writeNpmrc`-style rewrite of
|
|
994
|
+
* `targetPath` happen non-destructively: a restore path has a baked-in
|
|
995
|
+
* source to copy back when the org transitions to `not-configured`.
|
|
996
|
+
*
|
|
997
|
+
* Uses `link(2)` rather than read + write so the snapshot is concurrency-
|
|
998
|
+
* safe. Two properties matter:
|
|
999
|
+
*
|
|
1000
|
+
* - The kernel serializes hardlink creation: only one of N concurrent
|
|
1001
|
+
* callers wins; the rest see EEXIST. No read-then-write window where
|
|
1002
|
+
* a slow caller could observe a `writeNpmrc`-rewritten target and
|
|
1003
|
+
* poison the backup with the private registry content.
|
|
1004
|
+
* - `writeNpmrc` rewrites the target via tmp + rename, which gives it
|
|
1005
|
+
* a fresh inode. The hardlink at `backupPath` keeps the original
|
|
1006
|
+
* inode (with the baked-in content) alive — that's the whole point.
|
|
1007
|
+
*
|
|
1008
|
+
* Silently no-ops in two cases:
|
|
1009
|
+
* - `backupPath` is already present (subsequent boots, or a second
|
|
1010
|
+
* call in the same boot after the first capture landed). Important so
|
|
1011
|
+
* a later `writeNpmrc`-rewritten target is never mistaken for the
|
|
1012
|
+
* baked-in baseline.
|
|
1013
|
+
* - `targetPath` does not exist (image without a baked-in `.npmrc`,
|
|
1014
|
+
* or a brownfield app dir that never had one). The natural "restore"
|
|
1015
|
+
* state is "no file", which the restore path handles by treating an
|
|
1016
|
+
* absent backup as "no file" too — for that case `restoreInitialNpmrc`
|
|
1017
|
+
* unlinks the target if asked, otherwise leaves it alone.
|
|
1018
|
+
*
|
|
1019
|
+
* Other errors degrade to a warning + no-op rather than throwing — the
|
|
1020
|
+
* capture is a best-effort safety net for the restore path, not a
|
|
1021
|
+
* precondition for the install path itself.
|
|
1022
|
+
*
|
|
1023
|
+
* Returns a `SnapshotInitialNpmrcOutcome` so a caller can tell whether a
|
|
1024
|
+
* usable backup now exists. This matters for the destructive restore path:
|
|
1025
|
+
* a silent `failed` (e.g. EXDEV) leaves no backup behind, so a later
|
|
1026
|
+
* `not-configured` transition must NOT mistake the absent backup for
|
|
1027
|
+
* "nothing to preserve" and unlink a real baked-in userconfig (APPS-4428).
|
|
1028
|
+
* Still never throws — the outcome is advisory, not an error channel.
|
|
1029
|
+
*
|
|
1030
|
+
* Shared with the home-npmrc writer (CLI startup, target =
|
|
1031
|
+
* `~/.superblocks/npmrc`) so both project-dir and home flows agree on the
|
|
1032
|
+
* snapshot/restore semantics.
|
|
1033
|
+
*/
|
|
1034
|
+
export async function snapshotInitialNpmrc(targetPath, backupPath) {
|
|
1035
|
+
try {
|
|
1036
|
+
await link(targetPath, backupPath);
|
|
1037
|
+
return "created";
|
|
1038
|
+
}
|
|
1039
|
+
catch (error) {
|
|
1040
|
+
const code = error?.code;
|
|
1041
|
+
if (code === "EEXIST") {
|
|
1042
|
+
// Snapshot already taken (prior boot or concurrent caller): a usable
|
|
1043
|
+
// backup exists even though this call didn't write it.
|
|
1044
|
+
return "exists";
|
|
1045
|
+
}
|
|
1046
|
+
if (code === "ENOENT") {
|
|
1047
|
+
// No baked-in target to snapshot; the restore baseline is "no file".
|
|
1048
|
+
return "no-source";
|
|
1049
|
+
}
|
|
1050
|
+
getLogger().warn("[npm-registry] failed to capture .npmrc backup; restore path will be unavailable until next pod boot", {
|
|
1051
|
+
target: targetPath,
|
|
1052
|
+
backup: backupPath,
|
|
1053
|
+
code,
|
|
1054
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1055
|
+
});
|
|
1056
|
+
return "failed";
|
|
1057
|
+
}
|
|
248
1058
|
}
|
|
249
1059
|
/**
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
1060
|
+
* Restore `targetPath` from `backupPath` when the org has transitioned
|
|
1061
|
+
* back to `not-configured` (e.g. last `org_npm_registry` row deleted).
|
|
1062
|
+
* Without this, a file previously rewritten by `writeNpmrc` stays in
|
|
1063
|
+
* place pointing at a registry the customer just removed — the exact
|
|
1064
|
+
* case APPS-4320 closes for project `.npmrc`, and APPS-4328 closes for
|
|
1065
|
+
* the home-level userconfig.
|
|
1066
|
+
*
|
|
1067
|
+
* Best-effort, never throws. The project-dir caller
|
|
1068
|
+
* (`maybeWriteNpmrcForDir` → `prepareForPrivateRegistry`) needs to fall
|
|
1069
|
+
* through to `stripResolvedFromLockfile` even when the restore can't
|
|
1070
|
+
* run; the home-level caller (`syncHomeNpmrc`) needs to finish dev-server
|
|
1071
|
+
* startup. Any error here degrades to a warn + no-op so the surrounding
|
|
1072
|
+
* install path stays alive.
|
|
1073
|
+
*
|
|
1074
|
+
* Skips the rewrite (without a warning) in three harmless cases:
|
|
1075
|
+
* - `targetPath` does not exist. The baked-in baseline for this dir is
|
|
1076
|
+
* "no file"; there is nothing to restore to.
|
|
1077
|
+
* - `backupPath` is missing AND `options.unlinkTargetWhenBackupMissing`
|
|
1078
|
+
* is not set. Expected on pods that never had `org_npm_registry`
|
|
1079
|
+
* configured (snapshot is gated on the configured branch), AND on
|
|
1080
|
+
* older pods whose target was rewritten before this commit landed.
|
|
1081
|
+
* In both cases the running file is what the pod has lived with —
|
|
1082
|
+
* leave it alone.
|
|
1083
|
+
* - `targetPath` already byte-matches `backupPath`. Avoids a no-op
|
|
1084
|
+
* rewrite that would bump mtime for downstream watchers (e.g. Tilt /
|
|
1085
|
+
* Vite file-sync).
|
|
253
1086
|
*
|
|
254
|
-
*
|
|
1087
|
+
* When `options.unlinkTargetWhenBackupMissing` is true and the backup is
|
|
1088
|
+
* absent but the target exists, the target is unlinked — see the option
|
|
1089
|
+
* comment.
|
|
255
1090
|
*/
|
|
256
|
-
export async function
|
|
257
|
-
|
|
258
|
-
|
|
1091
|
+
export async function restoreInitialNpmrc(targetPath, backupPath, options = {}) {
|
|
1092
|
+
let current;
|
|
1093
|
+
try {
|
|
1094
|
+
current = await readFile(targetPath, "utf-8");
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
const code = error?.code;
|
|
1098
|
+
if (code === "ENOENT") {
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
getLogger().warn("[npm-registry] failed to read target for restore comparison; skipping", {
|
|
1102
|
+
path: targetPath,
|
|
1103
|
+
code,
|
|
1104
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1105
|
+
});
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
let baked;
|
|
1109
|
+
try {
|
|
1110
|
+
baked = await readFile(backupPath, "utf-8");
|
|
1111
|
+
}
|
|
1112
|
+
catch (error) {
|
|
1113
|
+
const code = error?.code;
|
|
1114
|
+
if (code === "ENOENT") {
|
|
1115
|
+
if (options.unlinkTargetWhenBackupMissing) {
|
|
1116
|
+
// The caller owns the target file and there is no image-baked
|
|
1117
|
+
// baseline to fall back to. Leaving a stale rewrite in place is
|
|
1118
|
+
// worse than removing the file: subsequent npm/pnpm invocations
|
|
1119
|
+
// pinned at this userconfig will fall through to public npm
|
|
1120
|
+
// rather than an old configured-org registry/token.
|
|
1121
|
+
try {
|
|
1122
|
+
await unlink(targetPath);
|
|
1123
|
+
getLogger().info("[npm-registry] removed Superblocks-owned userconfig after org transitioned to not-configured (no backup to restore)", { path: targetPath });
|
|
1124
|
+
}
|
|
1125
|
+
catch (unlinkError) {
|
|
1126
|
+
const unlinkCode = unlinkError
|
|
1127
|
+
?.code;
|
|
1128
|
+
if (unlinkCode === "ENOENT") {
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
getLogger().warn("[npm-registry] failed to remove Superblocks-owned userconfig with no backup; leaving file in place", {
|
|
1132
|
+
path: targetPath,
|
|
1133
|
+
code: unlinkCode,
|
|
1134
|
+
error: unlinkError instanceof Error
|
|
1135
|
+
? unlinkError.message
|
|
1136
|
+
: String(unlinkError),
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
// Two harmless overlapping cases produce this for project-dir
|
|
1142
|
+
// callers:
|
|
1143
|
+
// 1. Pod never had `org_npm_registry` rows, so capture (gated on
|
|
1144
|
+
// the configured branch) never fired. The target is still the
|
|
1145
|
+
// baked-in image content — nothing to restore.
|
|
1146
|
+
// 2. Pod was configured-then-deconfigured before this commit
|
|
1147
|
+
// landed, so the target is a stale rewrite with no companion
|
|
1148
|
+
// snapshot. A recycle is required to recover, but per-install
|
|
1149
|
+
// logging here would be noisy and not actionable.
|
|
1150
|
+
// Either way, no-op silently.
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
getLogger().warn("[npm-registry] failed to read .npmrc backup for restore; skipping", {
|
|
1154
|
+
path: backupPath,
|
|
1155
|
+
code,
|
|
1156
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1157
|
+
});
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
if (current === baked) {
|
|
259
1161
|
return;
|
|
260
1162
|
}
|
|
261
|
-
|
|
262
|
-
|
|
1163
|
+
// Atomic copy: tmp + rename, mirroring `writeNpmrc`'s strategy so a
|
|
1164
|
+
// crash mid-restore cannot leave a half-written file that fails
|
|
1165
|
+
// `npm install` with a parse error.
|
|
1166
|
+
const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
|
|
1167
|
+
try {
|
|
1168
|
+
await writeFile(tmpPath, baked, { encoding: "utf-8", mode: 0o600 });
|
|
1169
|
+
await chmod(tmpPath, 0o600);
|
|
1170
|
+
await rename(tmpPath, targetPath);
|
|
1171
|
+
}
|
|
1172
|
+
catch (error) {
|
|
1173
|
+
await unlink(tmpPath).catch(() => { });
|
|
1174
|
+
// Swallow + warn rather than rethrow: the project-dir caller chains
|
|
1175
|
+
// into `stripResolvedFromLockfile` (APPS-4300), which is the actual
|
|
1176
|
+
// durable safety net for poisoned lockfiles. Propagating the restore
|
|
1177
|
+
// failure would skip that strip and leave the install path worse off
|
|
1178
|
+
// than pre-APPS-4320.
|
|
1179
|
+
getLogger().warn("[npm-registry] failed to restore .npmrc from backup; leaving current file in place", {
|
|
1180
|
+
path: targetPath,
|
|
1181
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1182
|
+
});
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
getLogger().info("[npm-registry] restored .npmrc from backup after org transitioned to not-configured", { path: targetPath });
|
|
263
1186
|
}
|
|
264
1187
|
/**
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* the
|
|
1188
|
+
* Marker on the appended ignore block so we can detect a prior append and
|
|
1189
|
+
* skip the work. Read-side checks are by exact-line match, not by this
|
|
1190
|
+
* marker, so a customer who hand-edits the file (deletes the marker, keeps
|
|
1191
|
+
* the `.npmrc` line) still gets the no-op behaviour they want.
|
|
1192
|
+
*/
|
|
1193
|
+
const NPMRC_GITIGNORE_MARKER = "# Superblocks: prevent committing private npm registry credentials";
|
|
1194
|
+
/**
|
|
1195
|
+
* True iff `<dir>` looks like a native-git checkout. Detects both regular
|
|
1196
|
+
* repos (`.git/` directory) and worktrees / submodules (`.git` file containing
|
|
1197
|
+
* a `gitdir:` pointer). Any stat error other than ENOENT is logged and the
|
|
1198
|
+
* function returns true, so a permission glitch fails closed (we will then
|
|
1199
|
+
* refuse to write the token-bearing `.npmrc` rather than assume "safe,
|
|
1200
|
+
* not a git checkout").
|
|
1201
|
+
*
|
|
1202
|
+
* Note: this only checks for `<dir>/.git` literally. `isInsideGitWorkTree`
|
|
1203
|
+
* is the broader check that also catches nested-repo layouts where `<dir>`
|
|
1204
|
+
* has no `.git` but a parent does — we need both because the parent-repo
|
|
1205
|
+
* case is also a `git add .` leak path (cursor[bot] review on PR #19642).
|
|
1206
|
+
*/
|
|
1207
|
+
async function dirHasNativeGit(dir) {
|
|
1208
|
+
const gitPath = path.join(dir, ".git");
|
|
1209
|
+
try {
|
|
1210
|
+
await stat(gitPath);
|
|
1211
|
+
return true;
|
|
1212
|
+
}
|
|
1213
|
+
catch (error) {
|
|
1214
|
+
const code = error?.code;
|
|
1215
|
+
if (code === "ENOENT") {
|
|
1216
|
+
return false;
|
|
1217
|
+
}
|
|
1218
|
+
getLogger().warn("[npm-registry] failed to stat .git; assuming native-git for safety", {
|
|
1219
|
+
path: gitPath,
|
|
1220
|
+
code,
|
|
1221
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1222
|
+
});
|
|
1223
|
+
return true;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* True iff `<dir>` is inside any git working tree — either because it has
|
|
1228
|
+
* its own `.git` (regular repo, worktree, submodule) OR because a parent
|
|
1229
|
+
* directory does. The nested-repo case is a real leak path: an app dir
|
|
1230
|
+
* with no local `.git` but a parent that does will be staged by
|
|
1231
|
+
* `git add .` run from the parent, and the `.npmrc` token would land in
|
|
1232
|
+
* the parent's commit history.
|
|
1233
|
+
*
|
|
1234
|
+
* Implementation: `git rev-parse --is-inside-work-tree` walks up from
|
|
1235
|
+
* `<dir>` and exits 0 inside a work tree, non-zero (128) outside. Uses
|
|
1236
|
+
* the same `runGit` scrubbed-env wrapper so a caller spawned by a git
|
|
1237
|
+
* hook can't poison the verdict via `GIT_*` env vars. If git itself is
|
|
1238
|
+
* missing or the invocation times out (`runGit` returns `code: null`),
|
|
1239
|
+
* returns false — there is no git to stage anything with, so the
|
|
1240
|
+
* non-native branch handles it via the proactively-created `.gitignore`.
|
|
1241
|
+
*/
|
|
1242
|
+
async function isInsideGitWorkTree(dir) {
|
|
1243
|
+
if (await dirHasNativeGit(dir)) {
|
|
1244
|
+
return true;
|
|
1245
|
+
}
|
|
1246
|
+
const result = await runGit(dir, ["rev-parse", "--is-inside-work-tree"]);
|
|
1247
|
+
if (result.code === 0) {
|
|
1248
|
+
return true;
|
|
1249
|
+
}
|
|
1250
|
+
if (result.code === 128) {
|
|
1251
|
+
// Canonical "not a git repository" exit. Definitively outside any
|
|
1252
|
+
// work tree — the non-native-git branch is correct.
|
|
1253
|
+
return false;
|
|
1254
|
+
}
|
|
1255
|
+
if (result.code === null) {
|
|
1256
|
+
// git binary missing or invocation timed out (`runGit` returns
|
|
1257
|
+
// `code: null` for any non-exit failure: ENOENT for `git`, ETIMEDOUT
|
|
1258
|
+
// after `GIT_VERIFY_TIMEOUT_MS`, signal kills, etc.).
|
|
1259
|
+
//
|
|
1260
|
+
// Returning `false` here routes us into the non-native-git branch,
|
|
1261
|
+
// which is the SAFER choice in every realistic scenario:
|
|
1262
|
+
//
|
|
1263
|
+
// 1. Local-`.git` case is unreachable here: `dirHasNativeGit` short-
|
|
1264
|
+
// circuited above when `<dir>/.git` exists, so we never hit
|
|
1265
|
+
// `rev-parse` for a regular repo at `<dir>`.
|
|
1266
|
+
//
|
|
1267
|
+
// 2. Nested-parent-repo case (the cursor[bot] concern): the parent
|
|
1268
|
+
// has `.git` but the app dir doesn't. If git is missing, the
|
|
1269
|
+
// customer cannot run `git add .` from the parent either — there
|
|
1270
|
+
// is no git binary to stage anything with. The fail-closed
|
|
1271
|
+
// requirement only matters when a working git is present, and a
|
|
1272
|
+
// working git would have returned `0` (or `128`), not `null`.
|
|
1273
|
+
// For the timeout case, a hung `rev-parse --is-inside-work-tree`
|
|
1274
|
+
// on a real repo would be unprecedented (the command only walks
|
|
1275
|
+
// parent directories looking for `.git`); a timeout in practice
|
|
1276
|
+
// means a broken environment where `git add` would also fail.
|
|
1277
|
+
//
|
|
1278
|
+
// 3. The non-native-git branch we route into still calls
|
|
1279
|
+
// `appendNpmrcIgnore` on `<dir>/.gitignore`. If that succeeds,
|
|
1280
|
+
// a future `git init` (or `git` reappearing on PATH) finds the
|
|
1281
|
+
// ignore entry already in place and is future-safe. If
|
|
1282
|
+
// `appendNpmrcIgnore` fails, `ensureNpmrcGitignored` returns
|
|
1283
|
+
// `{ignored:false, hasGit:false}` and the caller refuses to write
|
|
1284
|
+
// the credential-bearing `.npmrc`.
|
|
1285
|
+
//
|
|
1286
|
+
// Returning `true` here instead would force the per-clone exclude
|
|
1287
|
+
// fallback, but that fallback also calls `verifyNpmrcIgnoredByGit`
|
|
1288
|
+
// (same `runGit`) which would also return `code: null` and fail —
|
|
1289
|
+
// ending in the same fail-closed state but with an extra useless
|
|
1290
|
+
// attempt and a misleading "inside-repo" log line.
|
|
1291
|
+
return false;
|
|
1292
|
+
}
|
|
1293
|
+
// Unexpected non-zero from git. Assume inside-a-repo for safety; the
|
|
1294
|
+
// downstream verification will either confirm or fail closed.
|
|
1295
|
+
getLogger().warn("[npm-registry] git rev-parse --is-inside-work-tree returned unexpected exit code; assuming inside-repo for safety", { dir, code: result.code, stderr: result.stderr });
|
|
1296
|
+
return true;
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Returns true if the LAST matching rule for `.npmrc` in `text` is a
|
|
1300
|
+
* positive ignore (vs. a `!.npmrc` negation). Gitignore semantics are
|
|
1301
|
+
* "last matching pattern wins": a file containing `.npmrc` followed later
|
|
1302
|
+
* by `!.npmrc` re-includes the file, so a naive "does any line match
|
|
1303
|
+
* `.npmrc`?" check is a false positive that would let the append be
|
|
1304
|
+
* skipped and the credential-bearing `.npmrc` later staged on `git add .`.
|
|
1305
|
+
* Walk the file in order and remember the most recent verdict; return true
|
|
1306
|
+
* only when that verdict is a positive ignore.
|
|
1307
|
+
*
|
|
1308
|
+
* Matches npm/git's own grammar for our specific filename: a line is the
|
|
1309
|
+
* literal `.npmrc` (anchored anywhere) or `/.npmrc` (anchored to repo
|
|
1310
|
+
* root), with optional `!` negation prefix. We do NOT try to interpret
|
|
1311
|
+
* glob patterns like `*.rc` — false negatives just append a redundant
|
|
1312
|
+
* line (always safe under last-match), false positives would leak
|
|
1313
|
+
* credentials.
|
|
1314
|
+
*
|
|
1315
|
+
* Leading whitespace is NOT trimmed: in gitignore grammar leading spaces
|
|
1316
|
+
* are significant, so ` .npmrc` matches a file literally named ` .npmrc`,
|
|
1317
|
+
* NOT `.npmrc`. Trimming here would be a false positive (we'd skip the
|
|
1318
|
+
* append, but git would still stage `.npmrc`). Only two normalisations
|
|
1319
|
+
* happen, both to match git's own pattern parser exactly:
|
|
1320
|
+
*
|
|
1321
|
+
* - Trailing CR from CRLF line endings is stripped (CR is not part of
|
|
1322
|
+
* the path).
|
|
1323
|
+
* - UNESCAPED trailing spaces are stripped (`trim_trailing_spaces` in
|
|
1324
|
+
* git's `dir.c`). `.npmrc ` is the pattern `.npmrc`; `!.npmrc `
|
|
1325
|
+
* is the negation `!.npmrc`. This is the gpoulios-sb PR #19642
|
|
1326
|
+
* re-re-review case: a stray trailing space on `!.npmrc` made git
|
|
1327
|
+
* treat the line as a negation while a naive textual check still
|
|
1328
|
+
* matched the earlier `.npmrc` positive, so the helper declared the
|
|
1329
|
+
* file "ignored" and `maybeWriteNpmrcForDir` proceeded to write
|
|
1330
|
+
* credentials that a later `git init && git add .` would stage.
|
|
1331
|
+
* Tabs are NOT trimmed (git only trims spaces); `\<space>` escapes
|
|
1332
|
+
* a literal trailing space and is preserved.
|
|
1333
|
+
*
|
|
1334
|
+
* Other gitignore exotica (globs, character classes, `**`, backslash
|
|
1335
|
+
* escapes, leading `\!`, UTF-8 BOM, etc.) are NOT interpreted. All of
|
|
1336
|
+
* those cases fail the exact-match comparison below, so the textual
|
|
1337
|
+
* verdict is "not ignored" — the helper then appends a fresh positive
|
|
1338
|
+
* `.npmrc` rule and last-match-wins semantics make the file genuinely
|
|
1339
|
+
* ignored. False negatives are safe (one redundant line); false
|
|
1340
|
+
* positives leak credentials, so we err on the side of appending.
|
|
1341
|
+
*
|
|
1342
|
+
* This is the authoritative non-native-git check (gpoulios-sb PR #19642
|
|
1343
|
+
* re-review): the non-git branch in `ensureNpmrcGitignored` has no
|
|
1344
|
+
* `git check-ignore` follow-up, so the textual verdict must itself be
|
|
1345
|
+
* future-safe against order-sensitive negations — otherwise a later
|
|
1346
|
+
* `git init && git add .` in the same dir would stage a token-bearing
|
|
1347
|
+
* `.npmrc` that the helper had blessed.
|
|
1348
|
+
*
|
|
1349
|
+
* For native-git checkouts textual presence is still necessary but NOT
|
|
1350
|
+
* sufficient — `verifyNpmrcIgnoredByGit` is the authoritative check
|
|
1351
|
+
* because:
|
|
1352
|
+
*
|
|
1353
|
+
* - A negation in a DESCENDANT `.gitignore` (not just this file) can
|
|
1354
|
+
* re-include `.npmrc`; the textual check sees only the current file.
|
|
1355
|
+
* - An already-tracked `.npmrc` is staged on every commit regardless of
|
|
1356
|
+
* ignore rules; the textual check has no view of the index.
|
|
1357
|
+
*/
|
|
1358
|
+
function textIgnoresNpmrc(text) {
|
|
1359
|
+
let lastVerdict;
|
|
1360
|
+
for (const raw of text.split("\n")) {
|
|
1361
|
+
const line = trimTrailingGitignoreSpaces(raw.replace(/\r$/, ""));
|
|
1362
|
+
if (line === ".npmrc" || line === "/.npmrc") {
|
|
1363
|
+
lastVerdict = true;
|
|
1364
|
+
}
|
|
1365
|
+
else if (line === "!.npmrc" || line === "!/.npmrc") {
|
|
1366
|
+
lastVerdict = false;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return lastVerdict === true;
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Strip trailing unescaped spaces from a gitignore pattern, matching
|
|
1373
|
+
* git's `trim_trailing_spaces` (in `dir.c`) exactly.
|
|
1374
|
+
*
|
|
1375
|
+
* Algorithm (mirrors git's left-to-right walk):
|
|
1376
|
+
* - Track the position of the first trailing space.
|
|
1377
|
+
* - A `\<space>` pair is an escaped space and resets the tracker
|
|
1378
|
+
* (the space is part of the pattern).
|
|
1379
|
+
* - Any non-space character resets the tracker.
|
|
1380
|
+
* - Truncate at the first tracked trailing space.
|
|
1381
|
+
*
|
|
1382
|
+
* Only ASCII spaces (0x20) are trimmed. Tabs, NBSP, and other
|
|
1383
|
+
* whitespace are preserved — that's what git does. The CR strip happens
|
|
1384
|
+
* separately in `textIgnoresNpmrc`'s split loop because git treats CR as
|
|
1385
|
+
* a line-ending artefact, not a pattern character.
|
|
1386
|
+
*
|
|
1387
|
+
* Examples (matching git's behaviour):
|
|
1388
|
+
* "!.npmrc " → "!.npmrc" (trailing space trimmed)
|
|
1389
|
+
* "!.npmrc " → "!.npmrc" (multiple trailing spaces trimmed)
|
|
1390
|
+
* "!.npmrc\\ " → "!.npmrc\\ " (escaped space preserved)
|
|
1391
|
+
* "!.npmrc\\t" → "!.npmrc\\t" (tab preserved; not a space)
|
|
1392
|
+
* " .npmrc" → " .npmrc" (leading whitespace preserved)
|
|
1393
|
+
*/
|
|
1394
|
+
function trimTrailingGitignoreSpaces(line) {
|
|
1395
|
+
let lastSpace = -1;
|
|
1396
|
+
for (let i = 0; i < line.length; i++) {
|
|
1397
|
+
const ch = line[i];
|
|
1398
|
+
if (ch === "\\" && line[i + 1] === " ") {
|
|
1399
|
+
// Escaped space: skip the pair AND reset the tracker so the
|
|
1400
|
+
// escaped space cannot anchor a truncation.
|
|
1401
|
+
i++;
|
|
1402
|
+
lastSpace = -1;
|
|
1403
|
+
}
|
|
1404
|
+
else if (ch === " ") {
|
|
1405
|
+
if (lastSpace === -1) {
|
|
1406
|
+
lastSpace = i;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
else {
|
|
1410
|
+
lastSpace = -1;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
return lastSpace === -1 ? line : line.slice(0, lastSpace);
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Timeout (ms) for the `git` invocations in `verifyNpmrcIgnoredByGit`.
|
|
1417
|
+
* Generous enough for any realistic working tree (these commands scan only
|
|
1418
|
+
* the index and ignore rules, not the working tree), short enough that a
|
|
1419
|
+
* hung git invocation can't stall the install path.
|
|
1420
|
+
*/
|
|
1421
|
+
const GIT_VERIFY_TIMEOUT_MS = 5000;
|
|
1422
|
+
/**
|
|
1423
|
+
* Build a `process.env` copy with every `GIT_*` variable removed. Used by
|
|
1424
|
+
* `runGit` to insulate the helper from outer-process git state. See the
|
|
1425
|
+
* comment in `runGit` for the leak path this guards against.
|
|
1426
|
+
*/
|
|
1427
|
+
function scrubbedGitEnv() {
|
|
1428
|
+
const env = {};
|
|
1429
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
1430
|
+
if (!key.startsWith("GIT_")) {
|
|
1431
|
+
env[key] = value;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
return env;
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Run `git <args>` in `cwd`, returning the exit code without throwing. Any
|
|
1438
|
+
* non-spawn error (timeout, ENOENT for `git`, etc.) returns `{ code: null }`
|
|
1439
|
+
* so the caller fails closed. We use `execFile` (no shell) so the file
|
|
1440
|
+
* arguments cannot be reinterpreted as shell syntax.
|
|
1441
|
+
*/
|
|
1442
|
+
async function runGit(cwd, args) {
|
|
1443
|
+
try {
|
|
1444
|
+
const { stderr } = await execFileAsync("git", args, {
|
|
1445
|
+
cwd,
|
|
1446
|
+
timeout: GIT_VERIFY_TIMEOUT_MS,
|
|
1447
|
+
// Even if git emits a lot of stderr (unlikely for these tiny
|
|
1448
|
+
// commands), bound the buffer so we don't OOM the install path.
|
|
1449
|
+
maxBuffer: 1024 * 1024,
|
|
1450
|
+
// Scrub `GIT_*` env vars before invoking git. If a caller's process
|
|
1451
|
+
// was itself spawned by git (e.g. a `pre-commit` hook), git will
|
|
1452
|
+
// have exported `GIT_DIR`, `GIT_INDEX_FILE`, `GIT_WORK_TREE`,
|
|
1453
|
+
// `GIT_PREFIX`, etc. pointing at the OUTER repo. Those would
|
|
1454
|
+
// override `cwd` discovery and make `check-ignore` / `ls-files`
|
|
1455
|
+
// query the wrong repository, producing wrong fail-closed
|
|
1456
|
+
// verdicts. The npm install path is not normally invoked from a
|
|
1457
|
+
// git hook, but stripping these is cheap insurance.
|
|
1458
|
+
env: scrubbedGitEnv(),
|
|
1459
|
+
});
|
|
1460
|
+
return { code: 0, stderr };
|
|
1461
|
+
}
|
|
1462
|
+
catch (error) {
|
|
1463
|
+
// Promisified `execFile` throws an ExecException for both non-zero
|
|
1464
|
+
// exits and spawn failures. Distinguish them via `code`:
|
|
1465
|
+
// - number → process exited with that code
|
|
1466
|
+
// - string ('ENOENT', 'ETIMEDOUT', etc.) → spawn / signal failure
|
|
1467
|
+
const err = error;
|
|
1468
|
+
const code = err?.code;
|
|
1469
|
+
const stderr = err?.stderr ?? "";
|
|
1470
|
+
if (typeof code === "number") {
|
|
1471
|
+
return { code, stderr };
|
|
1472
|
+
}
|
|
1473
|
+
return { code: null, stderr };
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Use git itself to verify that `.npmrc` is BOTH effectively ignored AND
|
|
1478
|
+
* not already tracked from `dir`. Closes two leak paths that a textual
|
|
1479
|
+
* `.gitignore` check cannot:
|
|
1480
|
+
*
|
|
1481
|
+
* 1. Negated ignore rules. `.npmrc` followed later by `!.npmrc` (or by
|
|
1482
|
+
* `!.npmrc` in a descendant `.gitignore`) makes the file trackable.
|
|
1483
|
+
* `textIgnoresNpmrc` would still report true.
|
|
1484
|
+
* 2. Already-tracked `.npmrc`. Ignore rules do NOT protect files that
|
|
1485
|
+
* are already in the index — the next commit stages the rewrite. A
|
|
1486
|
+
* brownfield app that committed an empty `.npmrc` before the
|
|
1487
|
+
* private-registry feature is the canonical case.
|
|
1488
|
+
*
|
|
1489
|
+
* Returns true ONLY when both checks positively prove safety:
|
|
1490
|
+
* - `git check-ignore -q .npmrc` exits 0 (file is effectively ignored)
|
|
1491
|
+
* - `git ls-files --error-unmatch .npmrc` exits 1 (file is NOT tracked)
|
|
1492
|
+
*
|
|
1493
|
+
* Any other outcome — file not ignored, file tracked, git missing, not a
|
|
1494
|
+
* real repo, timeout, unexpected exit code — returns false. The caller
|
|
1495
|
+
* fails closed and refuses to write the credential-bearing `.npmrc`.
|
|
1496
|
+
*/
|
|
1497
|
+
async function verifyNpmrcIgnoredByGit(dir) {
|
|
1498
|
+
const checkIgnore = await runGit(dir, ["check-ignore", "-q", "--", ".npmrc"]);
|
|
1499
|
+
if (checkIgnore.code !== 0) {
|
|
1500
|
+
if (checkIgnore.code === 1) {
|
|
1501
|
+
getLogger().warn("[npm-registry] git check-ignore reports .npmrc is NOT effectively ignored (likely a `!.npmrc` negation downstream); refusing to write credential-bearing .npmrc", { dir });
|
|
1502
|
+
}
|
|
1503
|
+
else {
|
|
1504
|
+
getLogger().warn("[npm-registry] git check-ignore failed (not a real git repo, git missing, or timeout); refusing to write credential-bearing .npmrc", { dir, code: checkIgnore.code, stderr: checkIgnore.stderr });
|
|
1505
|
+
}
|
|
1506
|
+
return false;
|
|
1507
|
+
}
|
|
1508
|
+
const lsFiles = await runGit(dir, [
|
|
1509
|
+
"ls-files",
|
|
1510
|
+
"--error-unmatch",
|
|
1511
|
+
"--",
|
|
1512
|
+
".npmrc",
|
|
1513
|
+
]);
|
|
1514
|
+
if (lsFiles.code === 0) {
|
|
1515
|
+
// File is in the index. Even with `.gitignore` containing `.npmrc`,
|
|
1516
|
+
// rewriting the file with `_authToken=…` will stage on the next
|
|
1517
|
+
// commit because git tracks file content for already-tracked files
|
|
1518
|
+
// regardless of the ignore list.
|
|
1519
|
+
getLogger().warn("[npm-registry] .npmrc is already tracked by git; refusing to write credentials (next commit would stage the token)", { dir });
|
|
1520
|
+
return false;
|
|
1521
|
+
}
|
|
1522
|
+
if (lsFiles.code !== 1) {
|
|
1523
|
+
// 1 is the canonical "not tracked" exit (`error: pathspec '.npmrc' did
|
|
1524
|
+
// not match any file(s) known to git`). Other non-zero codes mean
|
|
1525
|
+
// something is wrong with the repo / our invocation — fail closed.
|
|
1526
|
+
getLogger().warn("[npm-registry] git ls-files returned an unexpected exit code; refusing to write credential-bearing .npmrc", { dir, code: lsFiles.code, stderr: lsFiles.stderr });
|
|
1527
|
+
return false;
|
|
1528
|
+
}
|
|
1529
|
+
return true;
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Append (or create) an ignore file at `targetPath` so that `.npmrc` is
|
|
1533
|
+
* excluded. Returns true on success, false on any I/O failure. `createIfMissing`
|
|
1534
|
+
* controls whether an absent file is created. All `ensureNpmrcGitignored`
|
|
1535
|
+
* call sites pass `true` (so a missing `.gitignore` or `.git/info/exclude`
|
|
1536
|
+
* is materialised before the credential write); the parameter is preserved
|
|
1537
|
+
* so future callers can opt into a strict "only append, never create"
|
|
1538
|
+
* behaviour without touching the body.
|
|
1539
|
+
*/
|
|
1540
|
+
async function appendNpmrcIgnore(targetPath, createIfMissing) {
|
|
1541
|
+
// Refuse to follow a symlink at the leaf path component on BOTH the read
|
|
1542
|
+
// and the write. `targetPath` is `<dir>/.gitignore` or
|
|
1543
|
+
// `<dir>/.git/info/exclude`; a crafted workspace could point either at a
|
|
1544
|
+
// file outside the app root. `readFile`/`writeFile` follow symlinks, so
|
|
1545
|
+
// without `O_NOFOLLOW` our ~70-byte ignore fragment could be written
|
|
1546
|
+
// through the link to an external target (cursor[bot] MEDIUM, colinhicks
|
|
1547
|
+
// review on PR #19642). Opening with `O_NOFOLLOW` fails with `ELOOP` when
|
|
1548
|
+
// the leaf is a symlink; we treat that as fail-closed (return false) so
|
|
1549
|
+
// `ensureNpmrcGitignored` refuses the credential write — same boundary
|
|
1550
|
+
// discipline as `persisted-checklist-store.ts` / PR #19495, but TOCTOU-free
|
|
1551
|
+
// because the kernel checks atomically at open time. `O_NOFOLLOW` is POSIX;
|
|
1552
|
+
// `?? 0` degrades to a plain open where it is absent (the dev-server runs
|
|
1553
|
+
// on Linux, where it is always defined).
|
|
1554
|
+
const noFollow = fsConstants.O_NOFOLLOW ?? 0;
|
|
1555
|
+
let existing;
|
|
1556
|
+
let readHandle;
|
|
1557
|
+
try {
|
|
1558
|
+
readHandle = await open(targetPath, fsConstants.O_RDONLY | noFollow);
|
|
1559
|
+
existing = await readHandle.readFile("utf-8");
|
|
1560
|
+
}
|
|
1561
|
+
catch (error) {
|
|
1562
|
+
const code = error?.code;
|
|
1563
|
+
if (code === "ELOOP") {
|
|
1564
|
+
getLogger().warn("[npm-registry] ignore file is a symlink; refusing to follow it for .npmrc enforcement", { path: targetPath });
|
|
1565
|
+
return false;
|
|
1566
|
+
}
|
|
1567
|
+
if (code !== "ENOENT") {
|
|
1568
|
+
getLogger().warn("[npm-registry] failed to read ignore file; skipping .npmrc enforcement", {
|
|
1569
|
+
path: targetPath,
|
|
1570
|
+
code,
|
|
1571
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1572
|
+
});
|
|
1573
|
+
return false;
|
|
1574
|
+
}
|
|
1575
|
+
if (!createIfMissing) {
|
|
1576
|
+
return false;
|
|
1577
|
+
}
|
|
1578
|
+
existing = undefined;
|
|
1579
|
+
}
|
|
1580
|
+
finally {
|
|
1581
|
+
await readHandle?.close();
|
|
1582
|
+
}
|
|
1583
|
+
if (existing !== undefined && textIgnoresNpmrc(existing)) {
|
|
1584
|
+
return true;
|
|
1585
|
+
}
|
|
1586
|
+
// Separate the appended block from any existing content with a blank line.
|
|
1587
|
+
// For an empty / freshly-created file start at column 0 with no leading
|
|
1588
|
+
// blank line (cosmetic, colinhicks review on PR #19642). For non-empty
|
|
1589
|
+
// content the byte output is unchanged.
|
|
1590
|
+
const base = existing ?? "";
|
|
1591
|
+
const sep = base.length === 0 ? "" : base.endsWith("\n") ? "\n" : "\n\n";
|
|
1592
|
+
const appended = `${base}${sep}${NPMRC_GITIGNORE_MARKER}\n.npmrc\n`;
|
|
1593
|
+
let writeHandle;
|
|
1594
|
+
try {
|
|
1595
|
+
// `O_TRUNC` is safe: we re-serialise the full file (existing content +
|
|
1596
|
+
// appended block), so truncation never drops data. `O_NOFOLLOW` closes
|
|
1597
|
+
// the TOCTOU window where the leaf is swapped for a symlink between the
|
|
1598
|
+
// read above and this write. Mode 0o666 matches the previous `writeFile`
|
|
1599
|
+
// default (umask trims it to 0o644) and is ignored when the file already
|
|
1600
|
+
// exists.
|
|
1601
|
+
writeHandle = await open(targetPath, fsConstants.O_WRONLY |
|
|
1602
|
+
fsConstants.O_CREAT |
|
|
1603
|
+
fsConstants.O_TRUNC |
|
|
1604
|
+
noFollow, 0o666);
|
|
1605
|
+
await writeHandle.writeFile(appended, "utf-8");
|
|
1606
|
+
getLogger().info("[npm-registry] appended .npmrc to ignore file to prevent credential commit", { path: targetPath });
|
|
1607
|
+
return true;
|
|
1608
|
+
}
|
|
1609
|
+
catch (error) {
|
|
1610
|
+
const code = error?.code;
|
|
1611
|
+
if (code === "ELOOP") {
|
|
1612
|
+
getLogger().warn("[npm-registry] ignore file became a symlink before write; refusing to write through it", { path: targetPath });
|
|
1613
|
+
return false;
|
|
1614
|
+
}
|
|
1615
|
+
getLogger().warn("[npm-registry] failed to append .npmrc to ignore file", {
|
|
1616
|
+
path: targetPath,
|
|
1617
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1618
|
+
});
|
|
1619
|
+
return false;
|
|
1620
|
+
}
|
|
1621
|
+
finally {
|
|
1622
|
+
await writeHandle?.close();
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Ensure `.npmrc` is git-ignored from `<dir>` before any credential-bearing
|
|
1627
|
+
* `.npmrc` is written.
|
|
1628
|
+
*
|
|
1629
|
+
* Why this matters: `maybeWriteNpmrcForDir` writes a `.npmrc` containing
|
|
1630
|
+
* `//host/:_authToken=<customer token>` at the app root. The DBFS sync
|
|
1631
|
+
* already excludes `.npmrc` from upload (`codeModeExcludedFiles` in
|
|
1632
|
+
* `file-system-helpers.ts`), but the native-git path calls `git.add(".")`
|
|
1633
|
+
* (socket-manager `gitCommitLocal`/`gitPull`, agent `git`/`git_raw` tools)
|
|
1634
|
+
* which stages everything not listed in the app's on-disk ignore. Without
|
|
1635
|
+
* this enforcement, a customer with a configured private registry pushes
|
|
1636
|
+
* their `_authToken` to their external git remote on the next commit. The
|
|
1637
|
+
* template `.gitignore` covers new apps; this helper covers the existing
|
|
1638
|
+
* fleet whose `.gitignore` predates the registry feature.
|
|
1639
|
+
*
|
|
1640
|
+
* Behaviour, in three layers:
|
|
1641
|
+
*
|
|
1642
|
+
* 1. Non-native-git (`<dir>/.git` does not exist):
|
|
1643
|
+
* - Append `.npmrc` to `<dir>/.gitignore`, or create the file if
|
|
1644
|
+
* it doesn't exist. We create `.gitignore` here (not just when
|
|
1645
|
+
* `.git` already exists) so a subsequent `git init` in the same
|
|
1646
|
+
* directory is future-safe: a customer who later turns on native
|
|
1647
|
+
* git for an app where a credential-bearing `.npmrc` already
|
|
1648
|
+
* sits on disk would otherwise stage the token on their first
|
|
1649
|
+
* `git add .` before any install path re-runs this helper.
|
|
1650
|
+
* - On success return `{ ignored: true, hasGit: false }`. On a
|
|
1651
|
+
* read or write failure (`.gitignore` is a directory, disk full,
|
|
1652
|
+
* permission denied) return `{ ignored: false, hasGit: false }`.
|
|
1653
|
+
* The caller MUST refuse to write the credential-bearing
|
|
1654
|
+
* `.npmrc` in that case: even though no native git checkout
|
|
1655
|
+
* exists today, a later `git init` (or wiring this dir into an
|
|
1656
|
+
* existing repo) would find the on-disk token and stage it on
|
|
1657
|
+
* the first `git add .` — exactly the future-git leak path
|
|
1658
|
+
* gpoulios-sb flagged in code review.
|
|
1659
|
+
*
|
|
1660
|
+
* 2. Native-git (`<dir>/.git` exists), `.gitignore` path:
|
|
1661
|
+
* - Read or create `.gitignore`, append `.npmrc` if missing. After
|
|
1662
|
+
* the append, verify with git itself (`verifyNpmrcIgnoredByGit`)
|
|
1663
|
+
* that the file is effectively ignored AND not already tracked.
|
|
1664
|
+
* Only then return `ignored: true`.
|
|
1665
|
+
*
|
|
1666
|
+
* 3. Native-git, `.git/info/exclude` fallback:
|
|
1667
|
+
* - Only attempted when the `.gitignore` write failed AND `.git` is a
|
|
1668
|
+
* directory (regular repo). `.git/info/exclude` is the per-clone
|
|
1669
|
+
* ignore file: not synced to remotes, but exactly as effective as
|
|
1670
|
+
* `.gitignore` at preventing `git add` from staging the file. The
|
|
1671
|
+
* fallback lets us still fail-closed when the working tree is
|
|
1672
|
+
* read-only but the local clone metadata is writable. After the
|
|
1673
|
+
* exclude append, the same `verifyNpmrcIgnoredByGit` check runs.
|
|
1674
|
+
*
|
|
1675
|
+
* Why git verification, not just text: a `.gitignore` containing `.npmrc`
|
|
1676
|
+
* is necessary but not sufficient. Two real leak paths bypass a naive
|
|
1677
|
+
* "any line matches" textual check:
|
|
1678
|
+
* - A later `!.npmrc` re-includes the file (gitignore is order-sensitive).
|
|
1679
|
+
* `textIgnoresNpmrc` is already negation-aware (returns true only when
|
|
1680
|
+
* the LAST matching rule is positive), so for this case the textual
|
|
1681
|
+
* check correctly reports "not ignored" and `appendNpmrcIgnore` writes
|
|
1682
|
+
* a fresh positive `.npmrc` line that becomes the new last-match.
|
|
1683
|
+
* This is the gpoulios-sb PR #19642 re-review "always append" remedy,
|
|
1684
|
+
* applied uniformly to both branches (non-native-git relies on it as
|
|
1685
|
+
* the authoritative check; native-git double-checks with git below).
|
|
1686
|
+
* - An already-tracked `.npmrc` stages on every commit regardless of
|
|
1687
|
+
* ignore rules. No textual check sees the index, so for native-git
|
|
1688
|
+
* `git ls-files --error-unmatch` is the only catch.
|
|
1689
|
+
*
|
|
1690
|
+
* Returns `{ ignored, hasGit }`. The caller's fail-closed signal is
|
|
1691
|
+
* `ignored === false` regardless of `hasGit`: any state in which we cannot
|
|
1692
|
+
* guarantee `.npmrc` is ignored from `<dir>` blocks the credential write,
|
|
1693
|
+
* because either the current native git checkout would stage the token on
|
|
1694
|
+
* the next `git add .`, or a future `git init` in the same dir would find
|
|
1695
|
+
* the token on disk and stage it on the first `git add .`. `hasGit` is
|
|
1696
|
+
* retained for logging only — it lets the warning distinguish the
|
|
1697
|
+
* "tomorrow's risk" case from the "today's risk" case.
|
|
1698
|
+
*/
|
|
1699
|
+
export async function ensureNpmrcGitignored(dir) {
|
|
1700
|
+
// `hasGit` covers BOTH the local-`.git` and nested-repo (parent has
|
|
1701
|
+
// `.git`) cases. The nested-repo case was the cursor[bot] PR #19642
|
|
1702
|
+
// review concern: an app dir inside a parent git repo has no local
|
|
1703
|
+
// `.git`, so a bare `dirHasNativeGit` check would treat it as
|
|
1704
|
+
// non-native-git and let credentials through; `git add .` run from the
|
|
1705
|
+
// parent would then stage `<dir>/.npmrc`. The local-`.git`-only check
|
|
1706
|
+
// is still needed for the `.git/info/exclude` fallback below (that
|
|
1707
|
+
// path only exists for regular repos, not nested-only layouts).
|
|
1708
|
+
const hasGit = await isInsideGitWorkTree(dir);
|
|
1709
|
+
const hasLocalDotGit = await dirHasNativeGit(dir);
|
|
1710
|
+
const gitignorePath = path.join(dir, ".gitignore");
|
|
1711
|
+
// Always create `.gitignore` if missing — even when `!hasGit` — so a
|
|
1712
|
+
// later `git init` in this dir finds the `.npmrc` entry already in
|
|
1713
|
+
// place and the first `git add .` cannot stage a credential file that
|
|
1714
|
+
// was written between the two events. See the layer-1 comment in this
|
|
1715
|
+
// function's docstring for the future-safe rationale.
|
|
1716
|
+
if (await appendNpmrcIgnore(gitignorePath, true)) {
|
|
1717
|
+
if (!hasGit) {
|
|
1718
|
+
return { ignored: true, hasGit: false };
|
|
1719
|
+
}
|
|
1720
|
+
if (await verifyNpmrcIgnoredByGit(dir)) {
|
|
1721
|
+
return { ignored: true, hasGit: true };
|
|
1722
|
+
}
|
|
1723
|
+
// Textual append succeeded but git disagrees: a downstream `!.npmrc`
|
|
1724
|
+
// negation, an already-tracked `.npmrc`, or `git` is unable to verify
|
|
1725
|
+
// (missing, broken repo, timeout). Fall through to the per-clone
|
|
1726
|
+
// exclude fallback — it can override `.gitignore` negations (exclude
|
|
1727
|
+
// rules are evaluated last) AND covers the "no git binary / bad repo"
|
|
1728
|
+
// case in the same way (the fallback verifies too, and fails closed).
|
|
1729
|
+
}
|
|
1730
|
+
if (!hasGit) {
|
|
1731
|
+
// Non-native-git, no parent repo either: the `.gitignore` append/create
|
|
1732
|
+
// failed (rare — disk full, permission denied, or `.gitignore` was a
|
|
1733
|
+
// directory). Fail-closed: the future-git leak path is still open.
|
|
1734
|
+
return { ignored: false, hasGit: false };
|
|
1735
|
+
}
|
|
1736
|
+
// Inside a git work tree: `.gitignore` enforcement failed (or git
|
|
1737
|
+
// verification rejected it). Try the per-clone exclude file as a
|
|
1738
|
+
// fallback. Only valid when `<dir>/.git` is a directory (regular repo
|
|
1739
|
+
// rooted here); for a nested-repo case (`hasGit` but no local
|
|
1740
|
+
// `.git`) we have no path to the parent's `.git/info/exclude` without
|
|
1741
|
+
// a `git rev-parse --git-dir`, and the same exclude in the parent
|
|
1742
|
+
// won't anchor to `<dir>` anyway. Fail closed in those cases.
|
|
1743
|
+
const dotGit = path.join(dir, ".git");
|
|
1744
|
+
let dotGitIsDir = false;
|
|
1745
|
+
if (hasLocalDotGit) {
|
|
1746
|
+
try {
|
|
1747
|
+
const st = await stat(dotGit);
|
|
1748
|
+
dotGitIsDir = st.isDirectory();
|
|
1749
|
+
}
|
|
1750
|
+
catch {
|
|
1751
|
+
// Already covered by `dirHasNativeGit` — if we got here, the path
|
|
1752
|
+
// existed at the time of that check. A subsequent disappearance is
|
|
1753
|
+
// odd but we still fail closed.
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
if (!dotGitIsDir) {
|
|
1757
|
+
getLogger().warn("[npm-registry] native-git checkout has no writable .gitignore and .git is not a regular directory; refusing to write .npmrc", { path: gitignorePath });
|
|
1758
|
+
return { ignored: false, hasGit: true };
|
|
1759
|
+
}
|
|
1760
|
+
const infoDir = path.join(dotGit, "info");
|
|
1761
|
+
try {
|
|
1762
|
+
await mkdir(infoDir, { recursive: true });
|
|
1763
|
+
}
|
|
1764
|
+
catch (error) {
|
|
1765
|
+
getLogger().warn("[npm-registry] failed to create .git/info directory for exclude fallback", {
|
|
1766
|
+
path: infoDir,
|
|
1767
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1768
|
+
});
|
|
1769
|
+
return { ignored: false, hasGit: true };
|
|
1770
|
+
}
|
|
1771
|
+
const excludePath = path.join(infoDir, "exclude");
|
|
1772
|
+
if (await appendNpmrcIgnore(excludePath, true)) {
|
|
1773
|
+
// Verify with git again. The exclude file is evaluated LAST in git's
|
|
1774
|
+
// ignore precedence, so it overrides a `!.npmrc` negation in
|
|
1775
|
+
// `.gitignore`. But it still cannot un-track an already-tracked file,
|
|
1776
|
+
// so `ls-files --error-unmatch` is still the gate.
|
|
1777
|
+
if (await verifyNpmrcIgnoredByGit(dir)) {
|
|
1778
|
+
return { ignored: true, hasGit: true };
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
return { ignored: false, hasGit: true };
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Convenience: invoke `writeNpmrc` against `<dir>/.npmrc` when the client
|
|
1785
|
+
* resolves to a configured (or stale) registry config. No-op when:
|
|
1786
|
+
*
|
|
1787
|
+
* - `client` is undefined (test-only opt-out — production code paths
|
|
1788
|
+
* always pass a client constructed by AiService); or
|
|
1789
|
+
* - the resolved config is `source: "unreachable"` (transient cold-cache
|
|
1790
|
+
* outage — we leave `.npmrc` whatever it currently is and reconcile on
|
|
1791
|
+
* the next successful fetch, rather than clobbering a last-known-good
|
|
1792
|
+
* private-registry `.npmrc` back to baseline during the outage window);
|
|
1793
|
+
* or
|
|
1794
|
+
* - the resolved config is `source: "not-configured"` (deliberate unset
|
|
1795
|
+
* observation). If a previously-written `.npmrc` exists that no longer
|
|
1796
|
+
* matches the baked-in `.npmrc.default`, restore it — see APPS-4320.
|
|
1797
|
+
*
|
|
1798
|
+
* On the first call per pod boot for `<dir>`, captures `<dir>/.npmrc` to
|
|
1799
|
+
* `<dir>/.npmrc.default` before any rewrite so the restore path above has
|
|
1800
|
+
* a source to copy back from.
|
|
1801
|
+
*
|
|
1802
|
+
* Returns the resolved fetch result so callers can branch on `source` for
|
|
1803
|
+
* observability, or `undefined` when no client was provided.
|
|
1804
|
+
*/
|
|
1805
|
+
export async function maybeWriteNpmrcForDir(dir, options = {}, client) {
|
|
1806
|
+
if (!client) {
|
|
1807
|
+
return undefined;
|
|
1808
|
+
}
|
|
1809
|
+
const result = await client.getConfig();
|
|
1810
|
+
if (result.source === "unreachable") {
|
|
1811
|
+
// Transient cold-cache outage. We do NOT know the org's current
|
|
1812
|
+
// registry state, so we refuse to act on this signal — neither
|
|
1813
|
+
// rewrite (no config to render) nor restore (would clobber a
|
|
1814
|
+
// last-known-good private-registry `.npmrc` back to the baked-in
|
|
1815
|
+
// baseline exactly during the outage window where the customer
|
|
1816
|
+
// needs us to fail closed). Next successful fetch will reconcile.
|
|
1817
|
+
// Symmetric with `syncHomeNpmrc`'s `skipped-unreachable` branch in
|
|
1818
|
+
// `home-npmrc.mts`.
|
|
1819
|
+
getLogger().warn("[npm-registry] server unreachable with no cached config; .npmrc left unchanged", { path: path.join(dir, ".npmrc") });
|
|
1820
|
+
return result;
|
|
1821
|
+
}
|
|
1822
|
+
const npmrcPath = path.join(dir, ".npmrc");
|
|
1823
|
+
const backupPath = path.join(dir, NPMRC_DEFAULT_FILENAME);
|
|
1824
|
+
if (!result.config.configured) {
|
|
1825
|
+
await restoreInitialNpmrc(npmrcPath, backupPath);
|
|
1826
|
+
if (result.config.allowInstallScripts === false) {
|
|
1827
|
+
// Even without registry rows the org policy disallows install
|
|
1828
|
+
// scripts. Bake `ignore-scripts=true` into the project `.npmrc`
|
|
1829
|
+
// so any npm/pnpm invocation (patch-package, husky, ad-hoc
|
|
1830
|
+
// `bash npm install`) honours the policy. `writeNpmrc` with a
|
|
1831
|
+
// policy-only config preserves existing scope lines (e.g. the
|
|
1832
|
+
// EE-baked `@superblocksteam:registry=`) via `preserveScopeLines`.
|
|
1833
|
+
//
|
|
1834
|
+
// Skip `snapshotInitialNpmrc`: on a repeated sync the target
|
|
1835
|
+
// already contains our policy file; snapshotting it would poison
|
|
1836
|
+
// the backup so a later flip to allowInstallScripts=true could
|
|
1837
|
+
// never restore the original baked content.
|
|
1838
|
+
await writeNpmrc(npmrcPath, result.config, options);
|
|
1839
|
+
}
|
|
1840
|
+
else {
|
|
1841
|
+
// Clean up a stale policy-only `.npmrc` left by a previous call
|
|
1842
|
+
// when the dir had no baked-in file to snapshot. In that case
|
|
1843
|
+
// `restoreInitialNpmrc` cannot restore the "no file" baseline
|
|
1844
|
+
// because `.npmrc.default` was never created. We detect the stale
|
|
1845
|
+
// file by checking that it contains only the policy line (no
|
|
1846
|
+
// registry entries) and remove it.
|
|
1847
|
+
try {
|
|
1848
|
+
const content = await readFile(npmrcPath, "utf-8");
|
|
1849
|
+
if (content.trim() === "ignore-scripts=true") {
|
|
1850
|
+
await unlink(npmrcPath);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
catch (e) {
|
|
1854
|
+
if (e?.code !== "ENOENT") {
|
|
1855
|
+
getLogger().warn("[npm-registry] failed to clean up stale policy-only .npmrc", {
|
|
1856
|
+
path: npmrcPath,
|
|
1857
|
+
error: e instanceof Error ? e.message : String(e),
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
return result;
|
|
1863
|
+
}
|
|
1864
|
+
// Ensure `.npmrc` is git-ignored from `<dir>` BEFORE we write the
|
|
1865
|
+
// credential-bearing file. Fail-closed in BOTH the native-git and
|
|
1866
|
+
// non-native-git cases:
|
|
1867
|
+
//
|
|
1868
|
+
// - Native-git, no writable ignore (neither `<dir>/.gitignore` nor
|
|
1869
|
+
// `<dir>/.git/info/exclude`): the next `git add .` would stage the
|
|
1870
|
+
// token and push it to the customer's external remote.
|
|
1871
|
+
//
|
|
1872
|
+
// - Non-native-git, `<dir>/.gitignore` could not be created (rare —
|
|
1873
|
+
// disk full, permission denied, or the path is a directory): no
|
|
1874
|
+
// immediate native-git leak, but a later `git init` in the same
|
|
1875
|
+
// dir would find the on-disk token with no ignore rule and stage
|
|
1876
|
+
// it on the first `git add .`. This is the exact future-git leak
|
|
1877
|
+
// path called out in PR #19642 code review.
|
|
1878
|
+
//
|
|
1879
|
+
// See `ensureNpmrcGitignored` for the full decision matrix.
|
|
1880
|
+
const ignoreResult = await ensureNpmrcGitignored(dir);
|
|
1881
|
+
if (!ignoreResult.ignored) {
|
|
1882
|
+
getLogger().warn(ignoreResult.hasGit
|
|
1883
|
+
? "[npm-registry] refusing to write .npmrc with credentials: native-git checkout has no writable ignore (neither .gitignore nor .git/info/exclude); would leak token on next `git add .`"
|
|
1884
|
+
: "[npm-registry] refusing to write .npmrc with credentials: could not create or update .gitignore (write failed); a later `git init` in this dir would stage the token on first `git add .`", { path: npmrcPath, dir, hasGit: ignoreResult.hasGit });
|
|
1885
|
+
return result;
|
|
1886
|
+
}
|
|
1887
|
+
// Snapshot the baked-in `.npmrc` before the first rewrite so the
|
|
1888
|
+
// not-configured branch above has a restore source. Gated on the
|
|
1889
|
+
// configured branch so a pod whose org was configured-then-deconfigured
|
|
1890
|
+
// before this commit landed (so `.npmrc` is already a rewritten value
|
|
1891
|
+
// with no companion `.npmrc.default`) does not snapshot the rewritten
|
|
1892
|
+
// file as the baked-in baseline. In that pre-existing-rewrite case
|
|
1893
|
+
// the restore path correctly no-ops; recovery requires a pod recycle.
|
|
1894
|
+
//
|
|
1895
|
+
// Snapshot AFTER the gitignore check (we don't need a backup if we're
|
|
1896
|
+
// refusing to write).
|
|
1897
|
+
await snapshotInitialNpmrc(npmrcPath, backupPath);
|
|
1898
|
+
await writeNpmrc(npmrcPath, result.config, options);
|
|
1899
|
+
return result;
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Materializes the private-registry `.npmrc` AND unconditionally strips
|
|
1903
|
+
* `resolved` URLs from any lockfile present in `dir`, so a subsequent
|
|
1904
|
+
* `npm install` is guaranteed to re-resolve through the active registry.
|
|
1905
|
+
* The `.npmrc` write is gated on whether the client resolves to a configured
|
|
1906
|
+
* registry; the lockfile strip runs regardless.
|
|
269
1907
|
*
|
|
270
1908
|
* Why the strip is unconditional (decoupled from the `.npmrc` write):
|
|
271
|
-
* - SaaS dev-server pods (staging/prod) run without
|
|
272
|
-
*
|
|
273
|
-
* `resolved` URLs via DBFS state (APPS-4300). Gating
|
|
274
|
-
*
|
|
275
|
-
*
|
|
1909
|
+
* - SaaS dev-server pods (staging/prod) often run without a configured
|
|
1910
|
+
* org npm registry, but their lockfiles can still be poisoned with
|
|
1911
|
+
* cross-registry `resolved` URLs via DBFS state (APPS-4300). Gating
|
|
1912
|
+
* the strip on the client's configured-ness meant the only safety net
|
|
1913
|
+
* never fired in the exact environment where it mattered. Always
|
|
1914
|
+
* stripping closes that read path.
|
|
276
1915
|
* - The mutation is transient: `npm install` rewrites `resolved` with
|
|
277
1916
|
* URLs from the active `.npmrc` registry on the very next line, so a
|
|
278
1917
|
* local dev whose lockfile pointed at npmjs.org sees the same URLs
|
|
@@ -288,13 +1927,14 @@ export async function maybeWriteNpmrcForDir(dir, options = {}, env = process.env
|
|
|
288
1927
|
* (only configure GHPR scope for ephemeral builds) is the actual durable
|
|
289
1928
|
* fix; this strip is the runtime safety net for already-poisoned DBFS state.
|
|
290
1929
|
*
|
|
291
|
-
* Returns the
|
|
292
|
-
* `
|
|
1930
|
+
* Returns the resolved fetch result when a client is provided (so callers
|
|
1931
|
+
* can branch on `source` for observability), or `undefined` when no client
|
|
1932
|
+
* was provided. The lockfile strip happens in both cases.
|
|
293
1933
|
*/
|
|
294
|
-
export async function prepareForPrivateRegistry(dir, options = {},
|
|
295
|
-
const
|
|
1934
|
+
export async function prepareForPrivateRegistry(dir, options = {}, client) {
|
|
1935
|
+
const result = await maybeWriteNpmrcForDir(dir, options, client);
|
|
296
1936
|
await stripResolvedFromLockfile(dir);
|
|
297
|
-
return
|
|
1937
|
+
return result;
|
|
298
1938
|
}
|
|
299
1939
|
/**
|
|
300
1940
|
* Removes the `resolved` field from every entry in `.packages` of an npm v2+
|
|
@@ -329,7 +1969,7 @@ export async function stripResolvedFromLockfile(dir) {
|
|
|
329
1969
|
stripResolvedFromLockfilePath(path.join(dir, "node_modules", ".package-lock.json")),
|
|
330
1970
|
]);
|
|
331
1971
|
}
|
|
332
|
-
async function stripResolvedFromLockfilePath(lockfilePath) {
|
|
1972
|
+
export async function stripResolvedFromLockfilePath(lockfilePath) {
|
|
333
1973
|
let raw;
|
|
334
1974
|
try {
|
|
335
1975
|
raw = await readFile(lockfilePath, "utf-8");
|
|
@@ -412,4 +2052,93 @@ async function stripResolvedFromLockfilePath(lockfilePath) {
|
|
|
412
2052
|
throw error;
|
|
413
2053
|
}
|
|
414
2054
|
}
|
|
2055
|
+
/**
|
|
2056
|
+
* APPS-4370. Build a disk-backed `LastKnownRegistryPolicyStore` rooted at
|
|
2057
|
+
* `<rootDir>/.superblocks/orgs/<orgId>/.registry-was-configured`.
|
|
2058
|
+
*
|
|
2059
|
+
* The marker's PRESENCE is the semantic signal; its content is informal
|
|
2060
|
+
* (an ISO timestamp for forensics — "when was this org first seen with a
|
|
2061
|
+
* configured registry"). Writes are best-effort: a write failure (EACCES
|
|
2062
|
+
* on a 0o500 parent, ENOSPC) is logged and swallowed. Reads return false
|
|
2063
|
+
* on any error so a transient IO problem doesn't degrade today's
|
|
2064
|
+
* "proceed" behaviour into a fail-closed install.
|
|
2065
|
+
*
|
|
2066
|
+
* APPS-4427: `markConfigured` is AWAITED by its caller
|
|
2067
|
+
* (`NpmRegistryClient.markPolicyConfigured`) before a `configured` / `stale`
|
|
2068
|
+
* resolution is handed back, so the marker is durably on disk before that
|
|
2069
|
+
* result is used — closing the crash window an un-awaited write would leave
|
|
2070
|
+
* open. The write stays async (`fs/promises`, per the ai-service no-sync-fs
|
|
2071
|
+
* rule) and is guarded to run at most once per process, so awaiting it stays
|
|
2072
|
+
* off the install hot path after the first configured resolution.
|
|
2073
|
+
*
|
|
2074
|
+
* The `rootDir` is injected (rather than hard-coded to `os.homedir()`)
|
|
2075
|
+
* so tests can isolate to a tmp directory and so the dev-server can
|
|
2076
|
+
* point the store at the same disk it already manages
|
|
2077
|
+
* (`~/.superblocks/...`). The per-org path keeps multiple orgs running
|
|
2078
|
+
* against the same machine from contaminating each other's marker state.
|
|
2079
|
+
*/
|
|
2080
|
+
export function createDiskRegistryPolicyStore(opts) {
|
|
2081
|
+
const orgDir = path.join(opts.rootDir, ".superblocks", "orgs", opts.organizationId);
|
|
2082
|
+
const markerPath = path.join(orgDir, ".registry-was-configured");
|
|
2083
|
+
// APPS-4427: write at most once per process. The marker's PRESENCE is the
|
|
2084
|
+
// signal, so re-writing on every `configured`/`stale` resolution is pure
|
|
2085
|
+
// overhead — and since the caller now AWAITS the write, guarding here keeps
|
|
2086
|
+
// it off the install hot path after the first configured resolution. Only
|
|
2087
|
+
// set on a SUCCESSFUL write, so a transient failure is retried next call.
|
|
2088
|
+
let marked = false;
|
|
2089
|
+
return {
|
|
2090
|
+
async markConfigured() {
|
|
2091
|
+
if (marked) {
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
try {
|
|
2095
|
+
// Async (`fs/promises`) per the ai-service no-sync-fs rule. Durability
|
|
2096
|
+
// comes from the caller awaiting this before handing back the
|
|
2097
|
+
// `configured`/`stale` resolution (see `markPolicyConfigured`), not
|
|
2098
|
+
// from blocking the event loop with a sync write.
|
|
2099
|
+
await mkdir(orgDir, { recursive: true });
|
|
2100
|
+
// An ISO timestamp is enough to debug "when did we first see this
|
|
2101
|
+
// org configured" without growing the file's semantic surface
|
|
2102
|
+
// (presence is the signal).
|
|
2103
|
+
await writeFile(markerPath, new Date().toISOString() + "\n", "utf-8");
|
|
2104
|
+
marked = true;
|
|
2105
|
+
}
|
|
2106
|
+
catch (err) {
|
|
2107
|
+
getLogger().warn("[npm-registry] failed to write registry-policy marker; ignoring", {
|
|
2108
|
+
path: markerPath,
|
|
2109
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
},
|
|
2113
|
+
async hasEverBeenConfigured() {
|
|
2114
|
+
try {
|
|
2115
|
+
await stat(markerPath);
|
|
2116
|
+
return true;
|
|
2117
|
+
}
|
|
2118
|
+
catch (err) {
|
|
2119
|
+
const code = err?.code;
|
|
2120
|
+
if (code === "ENOENT") {
|
|
2121
|
+
// No marker → never been configured. The common case; not a
|
|
2122
|
+
// log-worthy event.
|
|
2123
|
+
return false;
|
|
2124
|
+
}
|
|
2125
|
+
// Anything else (EACCES on a clamped-perms dir, EIO) gets
|
|
2126
|
+
// surfaced at warn level so a misconfigured deployment is
|
|
2127
|
+
// visible, but we still resolve to false so the install path
|
|
2128
|
+
// preserves current behaviour.
|
|
2129
|
+
getLogger().warn("[npm-registry] failed to read registry-policy marker; treating as not-configured", {
|
|
2130
|
+
path: markerPath,
|
|
2131
|
+
code,
|
|
2132
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2133
|
+
});
|
|
2134
|
+
return false;
|
|
2135
|
+
}
|
|
2136
|
+
},
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
// Re-export the npm/pnpm install-error classifier + structured error types so
|
|
2140
|
+
// consumers of the `/npm-registry` entry (e.g. the CLI dev server) can classify
|
|
2141
|
+
// installs without reaching into ai-service internals. APPS-4450.
|
|
2142
|
+
export { parseNpmJsonError, parseNpmJsonDiagnostic, classifyNpmExecStderr, } from "./npm-error-parser.js";
|
|
2143
|
+
export { NpmInstallBlocked, NpmInstallError, scrubSecrets } from "../types.js";
|
|
415
2144
|
//# sourceMappingURL=npm-registry.js.map
|