@objectsws/s-portal 0.1.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/MANUAL_TEST.md +99 -0
- package/README.md +124 -0
- package/dist/detect.d.ts +11 -0
- package/dist/detect.js +47 -0
- package/dist/discard.d.ts +24 -0
- package/dist/discard.js +148 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +236 -0
- package/dist/portalApi.d.ts +50 -0
- package/dist/portalApi.js +41 -0
- package/dist/prompts.d.ts +19 -0
- package/dist/prompts.js +87 -0
- package/dist/scaffold.d.ts +15 -0
- package/dist/scaffold.js +100 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.js +36 -0
- package/package.json +40 -0
- package/templates/portal.README.md +29 -0
- package/templates/portal.client.server.ts +53 -0
- package/templates/portal.heartbeat.server.ts +28 -0
- package/templates/portal.notify.server.ts +79 -0
- package/templates/routes.api.portal-sync.tsx +90 -0
- package/templates/routes.healthz.ts +30 -0
package/MANUAL_TEST.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Manual test path — `@objects/s-portal` connect / discard
|
|
2
|
+
|
|
3
|
+
Use this checklist after portal APIs and the CLI package are built.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Apps Portal running locally or on a reachable host
|
|
8
|
+
- A Shopify Remix app checkout (or any folder with `shopify.app.toml` + `app/routes`)
|
|
9
|
+
- Node 18+
|
|
10
|
+
|
|
11
|
+
## Connect
|
|
12
|
+
|
|
13
|
+
1. **Rotate credentials** on the app detail page in the portal
|
|
14
|
+
- Open the app → **Generate credentials** / **Rotate credentials**
|
|
15
|
+
- Copy **Client Id** and **Client Secret** (secret shown once)
|
|
16
|
+
|
|
17
|
+
2. **Build the CLI** (from this monorepo, until published to npm)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd packages/s-portal-cli
|
|
21
|
+
npm install
|
|
22
|
+
npm run build
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
3. **Run connect** from the Shopify Remix app root
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
node /path/to/apps-portal/packages/s-portal-cli/dist/index.js connect \
|
|
29
|
+
--portal-url http://localhost:5173 \
|
|
30
|
+
--client-id portal-your-handle \
|
|
31
|
+
--client-secret '…' \
|
|
32
|
+
--remote-base-url https://your-tunnel.example.com \
|
|
33
|
+
--yes
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or interactive: `node …/dist/index.js` (omit flags; default command is `connect`).
|
|
37
|
+
|
|
38
|
+
4. **Confirm scaffold**
|
|
39
|
+
- `portal/client.server.ts`, `heartbeat.server.ts`, `notify.server.ts`, `README.md`
|
|
40
|
+
- `app/routes/healthz.ts`, `app/routes/api.portal-sync.tsx`
|
|
41
|
+
- `.env` contains `PORTAL_*` and `HEARTBEAT_INTERVAL_MS`
|
|
42
|
+
- `app/entry.server.*` has the scaffolded `startHeartbeatLoop()` markers
|
|
43
|
+
|
|
44
|
+
5. **Verify in portal UI**
|
|
45
|
+
- App detail → Remote Base URL matches what you entered
|
|
46
|
+
- Active Check URL = `{remoteBaseUrl}/healthz`
|
|
47
|
+
- Sync connection enabled
|
|
48
|
+
- **Verify connection** (token exchange) once the remote app is deployed/reachable
|
|
49
|
+
- Optional: **Manual sync** after implementing the merchants query (`id` + `shop` required)
|
|
50
|
+
|
|
51
|
+
6. **Verify active check**
|
|
52
|
+
- Deploy or tunnel so `{remoteBaseUrl}/healthz` returns 200 JSON
|
|
53
|
+
- Monitor settings → run **Manual check** (or wait for scheduled probe)
|
|
54
|
+
- Expect status **up** when healthz is healthy
|
|
55
|
+
|
|
56
|
+
7. **Re-run idempotency**
|
|
57
|
+
- Run connect again with the same Remote Base URL
|
|
58
|
+
- Expect existing files skipped (or overwrite with `--force`) and connection updated
|
|
59
|
+
|
|
60
|
+
## Discard
|
|
61
|
+
|
|
62
|
+
8. **Run discard** from the same Shopify app root
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
node /path/to/apps-portal/packages/s-portal-cli/dist/index.js discard --yes
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or local-only (no portal API):
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
node …/dist/index.js discard --yes --local-only
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
9. **Confirm local cleanup**
|
|
75
|
+
- Owned scaffold files removed (`portal/*`, `healthz`, `api.portal-sync`)
|
|
76
|
+
- Heartbeat markers removed from `entry.server` (or a note if you rewrote them)
|
|
77
|
+
- `PORTAL_*` / `HEARTBEAT_INTERVAL_MS` stripped from `.env`; other keys untouched
|
|
78
|
+
- Modified scaffold files skipped unless `--force`
|
|
79
|
+
|
|
80
|
+
10. **Confirm soft disconnect in portal UI**
|
|
81
|
+
- Remote Base URL cleared / sync disabled
|
|
82
|
+
- Active check disabled / target cleared
|
|
83
|
+
- **Client Id still present**; secret still configured (no rotation)
|
|
84
|
+
- Reconnect works by running `connect` again with the same credentials
|
|
85
|
+
|
|
86
|
+
## API smoke (optional)
|
|
87
|
+
|
|
88
|
+
With credentials:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# whoami — HMAC over empty body
|
|
92
|
+
# connect — HMAC over raw JSON body
|
|
93
|
+
# disconnect — HMAC over "{}"
|
|
94
|
+
# See packages/s-portal-cli/src/portalApi.ts for header scheme
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Expect `GET /api/cli/whoami` → `{ ok, app: { id, name, handle }, … }`
|
|
98
|
+
Expect `POST /api/cli/connect` → `{ ok, syncBaseUrl, activeCheckUrl, verified, portal: { heartbeatUrl, webhookUrl } }`
|
|
99
|
+
Expect `POST /api/cli/disconnect` → `{ ok, appId, disconnected: true }`
|
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# `@objects/s-portal`
|
|
2
|
+
|
|
3
|
+
CLI that scaffolds Apps Portal sync into a Shopify Remix app and registers your **Remote Base URL** with the portal sync engine.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
1. Create/link the app in **Apps Portal**.
|
|
8
|
+
2. On the app detail page, click **Generate credentials** and copy **Client Id** + **Client Secret**.
|
|
9
|
+
3. Have your deployed Shopify app public origin ready (Remote Base URL).
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
From your Shopify Remix app root:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @objects/s-portal
|
|
17
|
+
# same as:
|
|
18
|
+
npx @objects/s-portal connect
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Non-interactive:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx @objects/s-portal connect \
|
|
25
|
+
--portal-url https://your-portal.example.com \
|
|
26
|
+
--client-id portal-my-app \
|
|
27
|
+
--client-secret '…' \
|
|
28
|
+
--remote-base-url https://my-shopify-app.example.com \
|
|
29
|
+
--yes
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Scaffold only (no portal API calls):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx @objects/s-portal connect --scaffold-only
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Discard (tear down)
|
|
39
|
+
|
|
40
|
+
Removes CLI-scaffolded files, strips `PORTAL_*` / `HEARTBEAT_INTERVAL_MS` from `.env`, unpatches the heartbeat markers in `entry.server`, and **soft-disconnects** on the portal (clears Remote Base URL + disables sync/active check; **keeps** Client Id/Secret).
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx @objects/s-portal discard
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Non-interactive:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx @objects/s-portal discard --yes
|
|
50
|
+
# or skip portal API:
|
|
51
|
+
npx @objects/s-portal discard --yes --local-only
|
|
52
|
+
# delete scaffold files even if you edited them:
|
|
53
|
+
npx @objects/s-portal discard --yes --force
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Credentials for disconnect are resolved from flags → `.env` → prompts. If missing, local cleanup still runs and portal disconnect is skipped with a warning.
|
|
57
|
+
|
|
58
|
+
## Flow
|
|
59
|
+
|
|
60
|
+
**Connect**
|
|
61
|
+
|
|
62
|
+
1. Detect Shopify Remix app
|
|
63
|
+
2. Scaffold `portal/` helpers + `healthz` + `api.portal-sync` routes
|
|
64
|
+
3. Prompt Portal URL, Client Id, Client Secret
|
|
65
|
+
4. Confirm app (`GET /api/cli/whoami`)
|
|
66
|
+
5. Prompt Remote Base URL → Active Check = `{base}/healthz`
|
|
67
|
+
6. `POST /api/cli/connect` → write `.env` → **Connected**
|
|
68
|
+
|
|
69
|
+
**Discard**
|
|
70
|
+
|
|
71
|
+
1. Confirm (unless `--yes`)
|
|
72
|
+
2. `POST /api/cli/disconnect` (unless `--local-only` / missing creds)
|
|
73
|
+
3. Remove owned scaffold files + unpatch entry.server + strip env → **Discarded**
|
|
74
|
+
|
|
75
|
+
## Publish to npm
|
|
76
|
+
|
|
77
|
+
Publish from this package directory (not the monorepo root).
|
|
78
|
+
|
|
79
|
+
### One-time setup
|
|
80
|
+
|
|
81
|
+
1. Create or join the npm org **`objects`** (package name is `@objects/s-portal`).
|
|
82
|
+
2. `npm login` with an account that can publish under `@objects`.
|
|
83
|
+
3. Confirm: `npm access list packages @objects` (or npm Dashboard → Orgs).
|
|
84
|
+
|
|
85
|
+
### Every release
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
cd packages/s-portal-cli
|
|
89
|
+
npm install
|
|
90
|
+
npm run build
|
|
91
|
+
npm pack --dry-run # should list dist/, templates/, README.md, MANUAL_TEST.md
|
|
92
|
+
npm version patch # bump from 0.1.0 on later releases
|
|
93
|
+
npm publish --access public
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Scoped packages need `--access public` for free public installs.
|
|
97
|
+
|
|
98
|
+
### Use in any Shopify app folder
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
cd /path/to/other-shopify-app
|
|
102
|
+
npx @objects/s-portal@latest
|
|
103
|
+
npx @objects/s-portal discard --yes
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Until published, run the built binary directly:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
node /path/to/apps-portal/packages/s-portal-cli/dist/index.js connect
|
|
110
|
+
node /path/to/apps-portal/packages/s-portal-cli/dist/index.js discard --yes --local-only
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Local development (this monorepo)
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
cd packages/s-portal-cli
|
|
117
|
+
npm install
|
|
118
|
+
npm run build
|
|
119
|
+
node dist/index.js --help
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Manual test checklist
|
|
123
|
+
|
|
124
|
+
See [MANUAL_TEST.md](./MANUAL_TEST.md) — rotate credentials → CLI connect → verify sync + active check → discard.
|
package/dist/detect.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type DetectResult = {
|
|
2
|
+
root: string;
|
|
3
|
+
hasShopifyToml: boolean;
|
|
4
|
+
hasAppRoutes: boolean;
|
|
5
|
+
packageName: string | null;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Detect a Shopify Remix / React Router app from the current working directory
|
|
9
|
+
* (or nearest parent with shopify.app.toml / app/routes).
|
|
10
|
+
*/
|
|
11
|
+
export declare function detectShopifyApp(cwd?: string): DetectResult;
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Detect a Shopify Remix / React Router app from the current working directory
|
|
5
|
+
* (or nearest parent with shopify.app.toml / app/routes).
|
|
6
|
+
*/
|
|
7
|
+
export function detectShopifyApp(cwd = process.cwd()) {
|
|
8
|
+
let dir = path.resolve(cwd);
|
|
9
|
+
const rootStop = path.parse(dir).root;
|
|
10
|
+
while (true) {
|
|
11
|
+
const toml = path.join(dir, "shopify.app.toml");
|
|
12
|
+
const routes = path.join(dir, "app", "routes");
|
|
13
|
+
const pkgPath = path.join(dir, "package.json");
|
|
14
|
+
const hasShopifyToml = fs.existsSync(toml);
|
|
15
|
+
const hasAppRoutes = fs.existsSync(routes);
|
|
16
|
+
if (hasShopifyToml || hasAppRoutes) {
|
|
17
|
+
let packageName = null;
|
|
18
|
+
if (fs.existsSync(pkgPath)) {
|
|
19
|
+
try {
|
|
20
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
21
|
+
packageName = pkg.name ?? null;
|
|
22
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
23
|
+
const looksShopify = hasShopifyToml ||
|
|
24
|
+
Boolean(deps["@shopify/shopify-app-remix"]) ||
|
|
25
|
+
Boolean(deps["@shopify/shopify-app-react-router"]);
|
|
26
|
+
if (!looksShopify && !hasShopifyToml) {
|
|
27
|
+
// Keep walking if this is only a generic app/routes folder
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
return { root: dir, hasShopifyToml, hasAppRoutes, packageName };
|
|
31
|
+
}
|
|
32
|
+
if (hasShopifyToml || hasAppRoutes) {
|
|
33
|
+
return { root: dir, hasShopifyToml, hasAppRoutes, packageName };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { root: dir, hasShopifyToml, hasAppRoutes, packageName: null };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { root: dir, hasShopifyToml, hasAppRoutes, packageName };
|
|
41
|
+
}
|
|
42
|
+
if (dir === rootStop)
|
|
43
|
+
break;
|
|
44
|
+
dir = path.dirname(dir);
|
|
45
|
+
}
|
|
46
|
+
throw new Error("Could not detect a Shopify Remix app. Run this from your app root (look for shopify.app.toml or app/routes).");
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Files owned by the CLI scaffold (rel path → template name). */
|
|
2
|
+
export declare const SCAFFOLD_OWNED_FILES: Array<{
|
|
3
|
+
rel: string;
|
|
4
|
+
template: string;
|
|
5
|
+
}>;
|
|
6
|
+
export declare const PORTAL_ENV_KEYS: readonly ["PORTAL_HEARTBEAT_URL", "PORTAL_WEBHOOK_URL", "PORTAL_CLIENT_ID", "PORTAL_CLIENT_SECRET", "HEARTBEAT_INTERVAL_MS"];
|
|
7
|
+
export type DiscardLocalResult = {
|
|
8
|
+
removed: string[];
|
|
9
|
+
skipped: string[];
|
|
10
|
+
entryHint: string | null;
|
|
11
|
+
envStripped: string[];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Remove CLI-owned scaffold files, unpatch entry.server markers, strip portal env keys.
|
|
15
|
+
*/
|
|
16
|
+
export declare function discardLocalFiles(appRoot: string, opts?: {
|
|
17
|
+
force?: boolean;
|
|
18
|
+
}): DiscardLocalResult;
|
|
19
|
+
export declare function stripEnvKeys(appRoot: string, keys: string[]): {
|
|
20
|
+
path: string;
|
|
21
|
+
stripped: string[];
|
|
22
|
+
};
|
|
23
|
+
/** Read selected keys from .env (no secret logging by callers). */
|
|
24
|
+
export declare function readEnvKeys(appRoot: string, keys: string[]): Record<string, string>;
|
package/dist/discard.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readTemplate } from "./utils.js";
|
|
4
|
+
/** Files owned by the CLI scaffold (rel path → template name). */
|
|
5
|
+
export const SCAFFOLD_OWNED_FILES = [
|
|
6
|
+
{ rel: "portal/client.server.ts", template: "portal.client.server.ts" },
|
|
7
|
+
{ rel: "portal/heartbeat.server.ts", template: "portal.heartbeat.server.ts" },
|
|
8
|
+
{ rel: "portal/notify.server.ts", template: "portal.notify.server.ts" },
|
|
9
|
+
{ rel: "portal/README.md", template: "portal.README.md" },
|
|
10
|
+
{ rel: "app/routes/healthz.ts", template: "routes.healthz.ts" },
|
|
11
|
+
{ rel: "app/routes/api.portal-sync.tsx", template: "routes.api.portal-sync.tsx" },
|
|
12
|
+
];
|
|
13
|
+
export const PORTAL_ENV_KEYS = [
|
|
14
|
+
"PORTAL_HEARTBEAT_URL",
|
|
15
|
+
"PORTAL_WEBHOOK_URL",
|
|
16
|
+
"PORTAL_CLIENT_ID",
|
|
17
|
+
"PORTAL_CLIENT_SECRET",
|
|
18
|
+
"HEARTBEAT_INTERVAL_MS",
|
|
19
|
+
];
|
|
20
|
+
const ENTRY_CANDIDATES = [
|
|
21
|
+
"app/entry.server.tsx",
|
|
22
|
+
"app/entry.server.ts",
|
|
23
|
+
"app/entry.server.jsx",
|
|
24
|
+
];
|
|
25
|
+
const HEARTBEAT_IMPORT_RE = /^import\s+\{\s*startHeartbeatLoop\s*\}\s+from\s+["']\.\.\/portal\/heartbeat\.server["'];?\s*\n?/m;
|
|
26
|
+
const HEARTBEAT_CALL_RE = /\n*\/\/ Apps Portal heartbeat \(scaffolded by @objects\/s-portal\)\s*\n\s*startHeartbeatLoop\(\);\s*/;
|
|
27
|
+
function normalizeContent(s) {
|
|
28
|
+
return s.replace(/\r\n/g, "\n").trimEnd() + "\n";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Remove CLI-owned scaffold files, unpatch entry.server markers, strip portal env keys.
|
|
32
|
+
*/
|
|
33
|
+
export function discardLocalFiles(appRoot, opts = {}) {
|
|
34
|
+
const force = opts.force ?? false;
|
|
35
|
+
const removed = [];
|
|
36
|
+
const skipped = [];
|
|
37
|
+
for (const file of SCAFFOLD_OWNED_FILES) {
|
|
38
|
+
const abs = path.join(appRoot, file.rel);
|
|
39
|
+
if (!fs.existsSync(abs)) {
|
|
40
|
+
skipped.push(`${file.rel} (missing)`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (!force) {
|
|
44
|
+
const onDisk = normalizeContent(fs.readFileSync(abs, "utf8"));
|
|
45
|
+
const template = normalizeContent(readTemplate(file.template));
|
|
46
|
+
if (onDisk !== template) {
|
|
47
|
+
skipped.push(`${file.rel} (modified — use --force to delete)`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
fs.unlinkSync(abs);
|
|
52
|
+
removed.push(file.rel);
|
|
53
|
+
}
|
|
54
|
+
// Remove empty portal/ directory
|
|
55
|
+
const portalDir = path.join(appRoot, "portal");
|
|
56
|
+
if (fs.existsSync(portalDir)) {
|
|
57
|
+
try {
|
|
58
|
+
const remaining = fs.readdirSync(portalDir);
|
|
59
|
+
if (remaining.length === 0) {
|
|
60
|
+
fs.rmdirSync(portalDir);
|
|
61
|
+
removed.push("portal/ (empty dir)");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// ignore
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
let entryHint = null;
|
|
69
|
+
let entryTouched = false;
|
|
70
|
+
for (const rel of ENTRY_CANDIDATES) {
|
|
71
|
+
const abs = path.join(appRoot, rel);
|
|
72
|
+
if (!fs.existsSync(abs))
|
|
73
|
+
continue;
|
|
74
|
+
const before = fs.readFileSync(abs, "utf8");
|
|
75
|
+
if (!before.includes("startHeartbeatLoop") &&
|
|
76
|
+
!before.includes("portal/heartbeat.server")) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
let after = before;
|
|
80
|
+
const hadImport = HEARTBEAT_IMPORT_RE.test(after);
|
|
81
|
+
const hadCall = HEARTBEAT_CALL_RE.test(after);
|
|
82
|
+
after = after.replace(HEARTBEAT_IMPORT_RE, "");
|
|
83
|
+
after = after.replace(HEARTBEAT_CALL_RE, "\n");
|
|
84
|
+
after = after.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
85
|
+
if (after !== before && (hadImport || hadCall)) {
|
|
86
|
+
fs.writeFileSync(abs, after, "utf8");
|
|
87
|
+
removed.push(`${rel} (heartbeat unpatched)`);
|
|
88
|
+
entryTouched = true;
|
|
89
|
+
}
|
|
90
|
+
else if (before.includes("startHeartbeatLoop")) {
|
|
91
|
+
entryHint = `${rel} still references startHeartbeatLoop — remove manually if needed.`;
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
if (!entryTouched && !entryHint) {
|
|
96
|
+
entryHint = null;
|
|
97
|
+
}
|
|
98
|
+
const env = stripEnvKeys(appRoot, [...PORTAL_ENV_KEYS]);
|
|
99
|
+
return {
|
|
100
|
+
removed,
|
|
101
|
+
skipped,
|
|
102
|
+
entryHint,
|
|
103
|
+
envStripped: env.stripped,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export function stripEnvKeys(appRoot, keys) {
|
|
107
|
+
const envPath = path.join(appRoot, ".env");
|
|
108
|
+
if (!fs.existsSync(envPath)) {
|
|
109
|
+
return { path: envPath, stripped: [] };
|
|
110
|
+
}
|
|
111
|
+
const keySet = new Set(keys);
|
|
112
|
+
const lines = fs.readFileSync(envPath, "utf8").split(/\r?\n/);
|
|
113
|
+
const stripped = [];
|
|
114
|
+
const kept = [];
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
const m = line.match(/^([A-Z0-9_]+)=/);
|
|
117
|
+
if (m && keySet.has(m[1])) {
|
|
118
|
+
stripped.push(m[1]);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
kept.push(line);
|
|
122
|
+
}
|
|
123
|
+
if (stripped.length) {
|
|
124
|
+
const out = kept.join("\n").replace(/\n+$/, "\n");
|
|
125
|
+
fs.writeFileSync(envPath, out || "", "utf8");
|
|
126
|
+
}
|
|
127
|
+
return { path: envPath, stripped };
|
|
128
|
+
}
|
|
129
|
+
/** Read selected keys from .env (no secret logging by callers). */
|
|
130
|
+
export function readEnvKeys(appRoot, keys) {
|
|
131
|
+
const envPath = path.join(appRoot, ".env");
|
|
132
|
+
const out = {};
|
|
133
|
+
if (!fs.existsSync(envPath))
|
|
134
|
+
return out;
|
|
135
|
+
const keySet = new Set(keys);
|
|
136
|
+
for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) {
|
|
137
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
138
|
+
if (!m || !keySet.has(m[1]))
|
|
139
|
+
continue;
|
|
140
|
+
let val = m[2].trim();
|
|
141
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
142
|
+
(val.startsWith("'") && val.endsWith("'"))) {
|
|
143
|
+
val = val.slice(1, -1);
|
|
144
|
+
}
|
|
145
|
+
out[m[1]] = val;
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
package/dist/index.d.ts
ADDED