@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 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.
@@ -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>;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};