@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/dist/index.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { detectShopifyApp } from "./detect.js";
|
|
4
|
+
import { discardLocalFiles, readEnvKeys } from "./discard.js";
|
|
5
|
+
import { promptConfirmApp, promptConfirmDiscard, promptCredentials, promptRemoteBaseUrl, } from "./prompts.js";
|
|
6
|
+
import { cliConnect, cliDisconnect, cliWhoami } from "./portalApi.js";
|
|
7
|
+
import { mergeEnvFile, scaffoldPortalFiles } from "./scaffold.js";
|
|
8
|
+
import { normalizeBaseUrl } from "./utils.js";
|
|
9
|
+
const program = new Command();
|
|
10
|
+
program
|
|
11
|
+
.name("s-portal")
|
|
12
|
+
.description("Scaffold Apps Portal sync into a Shopify Remix app and register Remote Base URL");
|
|
13
|
+
program
|
|
14
|
+
.command("connect", { isDefault: true })
|
|
15
|
+
.description("Scaffold portal files and register Remote Base URL (default)")
|
|
16
|
+
.option("--portal-url <url>", "Apps Portal base URL")
|
|
17
|
+
.option("--client-id <id>", "Portal Client Id (portal-{handle})")
|
|
18
|
+
.option("--client-secret <secret>", "Portal Client Secret")
|
|
19
|
+
.option("--remote-base-url <url>", "Your deployed Shopify app origin")
|
|
20
|
+
.option("--force", "Overwrite existing scaffold files without prompting", false)
|
|
21
|
+
.option("--yes", "Skip interactive confirmations when flags provide values", false)
|
|
22
|
+
.option("--scaffold-only", "Only scaffold files; do not call portal APIs", false)
|
|
23
|
+
.action(async (opts) => {
|
|
24
|
+
try {
|
|
25
|
+
await runConnect(opts);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
29
|
+
console.error(`\nError: ${msg}\n`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
program
|
|
34
|
+
.command("discard")
|
|
35
|
+
.description("Remove CLI-scaffolded files, strip PORTAL_* from .env, and soft-disconnect from portal")
|
|
36
|
+
.option("--portal-url <url>", "Apps Portal base URL")
|
|
37
|
+
.option("--client-id <id>", "Portal Client Id (portal-{handle})")
|
|
38
|
+
.option("--client-secret <secret>", "Portal Client Secret")
|
|
39
|
+
.option("--yes", "Skip interactive confirmation", false)
|
|
40
|
+
.option("--local-only", "Only remove local files; do not call portal APIs", false)
|
|
41
|
+
.option("--force", "Delete scaffold files even if modified from the original template", false)
|
|
42
|
+
.action(async (opts) => {
|
|
43
|
+
try {
|
|
44
|
+
await runDiscard(opts);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
48
|
+
console.error(`\nError: ${msg}\n`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
async function runConnect(opts) {
|
|
53
|
+
console.log("\n@objects/s-portal — Apps Portal connect\n");
|
|
54
|
+
const detected = detectShopifyApp(process.cwd());
|
|
55
|
+
console.log(`Detected app root: ${detected.root}`);
|
|
56
|
+
if (detected.packageName)
|
|
57
|
+
console.log(`Package: ${detected.packageName}`);
|
|
58
|
+
const scaffold = await scaffoldPortalFiles(detected.root, { force: opts.force });
|
|
59
|
+
if (scaffold.written.length) {
|
|
60
|
+
console.log("\nScaffolded:");
|
|
61
|
+
for (const f of scaffold.written)
|
|
62
|
+
console.log(` + ${f}`);
|
|
63
|
+
}
|
|
64
|
+
if (scaffold.skipped.length) {
|
|
65
|
+
console.log("\nSkipped:");
|
|
66
|
+
for (const f of scaffold.skipped)
|
|
67
|
+
console.log(` · ${f}`);
|
|
68
|
+
}
|
|
69
|
+
console.log(`\n${scaffold.entryServerHint}`);
|
|
70
|
+
if (opts.scaffoldOnly) {
|
|
71
|
+
console.log("\nScaffold-only mode — done.\n");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let portalUrl = opts.portalUrl?.trim();
|
|
75
|
+
let clientId = opts.clientId?.trim();
|
|
76
|
+
let clientSecret = opts.clientSecret?.trim();
|
|
77
|
+
if (!portalUrl || !clientId || !clientSecret) {
|
|
78
|
+
const prompted = await promptCredentials({ portalUrl, clientId });
|
|
79
|
+
portalUrl = prompted.portalUrl;
|
|
80
|
+
clientId = prompted.clientId;
|
|
81
|
+
clientSecret = prompted.clientSecret;
|
|
82
|
+
}
|
|
83
|
+
portalUrl = normalizeBaseUrl(portalUrl);
|
|
84
|
+
console.log("\nLooking up app on portal…");
|
|
85
|
+
const whoami = await cliWhoami({ portalUrl, clientId, clientSecret });
|
|
86
|
+
if (!whoami.ok || !whoami.app) {
|
|
87
|
+
throw new Error(whoami.error ?? "whoami failed — check Client Id / Secret / Portal URL");
|
|
88
|
+
}
|
|
89
|
+
console.log(`\nApp: ${whoami.app.name}`);
|
|
90
|
+
console.log(`Handle: ${whoami.app.handle}`);
|
|
91
|
+
console.log(`App Id: ${whoami.app.id}`);
|
|
92
|
+
if (!opts.yes) {
|
|
93
|
+
const ok = await promptConfirmApp(whoami.app);
|
|
94
|
+
if (!ok) {
|
|
95
|
+
console.log("\nEnd Session — connection cancelled.\n");
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
let remoteBaseUrl = opts.remoteBaseUrl?.trim();
|
|
100
|
+
if (!remoteBaseUrl) {
|
|
101
|
+
remoteBaseUrl = await promptRemoteBaseUrl(whoami.connection?.syncBaseUrl ?? undefined);
|
|
102
|
+
}
|
|
103
|
+
remoteBaseUrl = normalizeBaseUrl(remoteBaseUrl);
|
|
104
|
+
const activeCheckUrl = `${remoteBaseUrl}/healthz`;
|
|
105
|
+
console.log(`Active Check URL: ${activeCheckUrl}`);
|
|
106
|
+
console.log("\nRegistering with sync engine…");
|
|
107
|
+
const connected = await cliConnect({
|
|
108
|
+
portalUrl,
|
|
109
|
+
clientId,
|
|
110
|
+
clientSecret,
|
|
111
|
+
remoteBaseUrl,
|
|
112
|
+
activeCheckUrl,
|
|
113
|
+
});
|
|
114
|
+
if (!connected.ok || !connected.portal) {
|
|
115
|
+
throw new Error(connected.error ?? "connect failed");
|
|
116
|
+
}
|
|
117
|
+
const env = mergeEnvFile(detected.root, {
|
|
118
|
+
PORTAL_HEARTBEAT_URL: connected.portal.heartbeatUrl,
|
|
119
|
+
PORTAL_WEBHOOK_URL: connected.portal.webhookUrl,
|
|
120
|
+
PORTAL_CLIENT_ID: clientId,
|
|
121
|
+
PORTAL_CLIENT_SECRET: clientSecret,
|
|
122
|
+
HEARTBEAT_INTERVAL_MS: "60000",
|
|
123
|
+
});
|
|
124
|
+
console.log("\nConnected.");
|
|
125
|
+
console.log(` syncBaseUrl: ${connected.syncBaseUrl}`);
|
|
126
|
+
console.log(` activeCheckUrl: ${connected.activeCheckUrl}`);
|
|
127
|
+
console.log(` token verified: ${connected.verified ? "yes" : `no (${connected.verifyError ?? "unreachable"})`}`);
|
|
128
|
+
console.log(` env updated: ${env.path} (${env.updated.join(", ")})`);
|
|
129
|
+
console.log(`
|
|
130
|
+
Next steps:
|
|
131
|
+
1. Deploy your app so ${remoteBaseUrl} is reachable.
|
|
132
|
+
2. Wire portal/notify.server.ts into your Shopify webhook handlers.
|
|
133
|
+
3. Implement the merchants query TODO in app/routes/api.portal-sync.tsx (id + shop required).
|
|
134
|
+
4. In the portal, Verify connection / Manual sync.
|
|
135
|
+
`);
|
|
136
|
+
}
|
|
137
|
+
async function runDiscard(opts) {
|
|
138
|
+
console.log("\n@objects/s-portal — Apps Portal discard\n");
|
|
139
|
+
const detected = detectShopifyApp(process.cwd());
|
|
140
|
+
console.log(`Detected app root: ${detected.root}`);
|
|
141
|
+
if (detected.packageName)
|
|
142
|
+
console.log(`Package: ${detected.packageName}`);
|
|
143
|
+
if (!opts.yes) {
|
|
144
|
+
const ok = await promptConfirmDiscard();
|
|
145
|
+
if (!ok) {
|
|
146
|
+
console.log("\nCancelled — nothing discarded.\n");
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Soft-disconnect on portal when possible
|
|
151
|
+
let portalDisconnected = false;
|
|
152
|
+
let portalWarning = null;
|
|
153
|
+
if (opts.localOnly) {
|
|
154
|
+
portalWarning = "Skipped portal disconnect (--local-only).";
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const fromEnv = readEnvKeys(detected.root, [
|
|
158
|
+
"PORTAL_HEARTBEAT_URL",
|
|
159
|
+
"PORTAL_CLIENT_ID",
|
|
160
|
+
"PORTAL_CLIENT_SECRET",
|
|
161
|
+
]);
|
|
162
|
+
let portalUrl = opts.portalUrl?.trim() ||
|
|
163
|
+
(fromEnv.PORTAL_HEARTBEAT_URL
|
|
164
|
+
? derivePortalOrigin(fromEnv.PORTAL_HEARTBEAT_URL)
|
|
165
|
+
: undefined);
|
|
166
|
+
let clientId = opts.clientId?.trim() || fromEnv.PORTAL_CLIENT_ID;
|
|
167
|
+
let clientSecret = opts.clientSecret?.trim() || fromEnv.PORTAL_CLIENT_SECRET;
|
|
168
|
+
if ((!portalUrl || !clientId || !clientSecret) && !opts.yes) {
|
|
169
|
+
const prompted = await promptCredentials({ portalUrl, clientId });
|
|
170
|
+
portalUrl = prompted.portalUrl;
|
|
171
|
+
clientId = prompted.clientId;
|
|
172
|
+
clientSecret = prompted.clientSecret;
|
|
173
|
+
}
|
|
174
|
+
if (portalUrl && clientId && clientSecret) {
|
|
175
|
+
portalUrl = normalizeBaseUrl(portalUrl);
|
|
176
|
+
console.log("\nSoft-disconnecting from portal…");
|
|
177
|
+
try {
|
|
178
|
+
const result = await cliDisconnect({
|
|
179
|
+
portalUrl,
|
|
180
|
+
clientId,
|
|
181
|
+
clientSecret,
|
|
182
|
+
});
|
|
183
|
+
if (!result.ok || !result.disconnected) {
|
|
184
|
+
portalWarning = result.error ?? "Portal disconnect failed";
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
portalDisconnected = true;
|
|
188
|
+
console.log(` Disconnected app ${result.appId}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
portalWarning =
|
|
193
|
+
error instanceof Error ? error.message : "Portal disconnect failed";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
portalWarning =
|
|
198
|
+
"Missing credentials — local files will still be removed; portal disconnect skipped.";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
console.log("\nRemoving local scaffold…");
|
|
202
|
+
const local = discardLocalFiles(detected.root, { force: opts.force });
|
|
203
|
+
if (local.removed.length) {
|
|
204
|
+
console.log("Removed:");
|
|
205
|
+
for (const f of local.removed)
|
|
206
|
+
console.log(` − ${f}`);
|
|
207
|
+
}
|
|
208
|
+
if (local.skipped.length) {
|
|
209
|
+
console.log("Skipped:");
|
|
210
|
+
for (const f of local.skipped)
|
|
211
|
+
console.log(` · ${f}`);
|
|
212
|
+
}
|
|
213
|
+
if (local.envStripped.length) {
|
|
214
|
+
console.log(`Env stripped: ${local.envStripped.join(", ")}`);
|
|
215
|
+
}
|
|
216
|
+
if (local.entryHint) {
|
|
217
|
+
console.log(`Note: ${local.entryHint}`);
|
|
218
|
+
}
|
|
219
|
+
console.log("\nDiscarded.");
|
|
220
|
+
console.log(` portal disconnect: ${portalDisconnected ? "yes (soft — credentials kept)" : "no"}`);
|
|
221
|
+
if (portalWarning) {
|
|
222
|
+
console.log(` warning: ${portalWarning}`);
|
|
223
|
+
}
|
|
224
|
+
console.log("");
|
|
225
|
+
}
|
|
226
|
+
/** Derive portal origin from PORTAL_HEARTBEAT_URL like https://x/api/monitor/heartbeat */
|
|
227
|
+
function derivePortalOrigin(heartbeatUrl) {
|
|
228
|
+
try {
|
|
229
|
+
const u = new URL(heartbeatUrl);
|
|
230
|
+
return `${u.protocol}//${u.host}`;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type WhoamiResponse = {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
error?: string;
|
|
4
|
+
app?: {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
handle: string;
|
|
8
|
+
};
|
|
9
|
+
clientId?: string;
|
|
10
|
+
connection?: {
|
|
11
|
+
syncBaseUrl: string | null;
|
|
12
|
+
isEnabled: boolean;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export type ConnectResponse = {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
error?: string;
|
|
18
|
+
appId?: string;
|
|
19
|
+
syncBaseUrl?: string;
|
|
20
|
+
activeCheckUrl?: string;
|
|
21
|
+
verified?: boolean;
|
|
22
|
+
verifyError?: string;
|
|
23
|
+
portal?: {
|
|
24
|
+
heartbeatUrl: string;
|
|
25
|
+
webhookUrl: string;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
export type DisconnectResponse = {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
error?: string;
|
|
31
|
+
appId?: string;
|
|
32
|
+
disconnected?: boolean;
|
|
33
|
+
};
|
|
34
|
+
export declare function cliWhoami(input: {
|
|
35
|
+
portalUrl: string;
|
|
36
|
+
clientId: string;
|
|
37
|
+
clientSecret: string;
|
|
38
|
+
}): Promise<WhoamiResponse>;
|
|
39
|
+
export declare function cliConnect(input: {
|
|
40
|
+
portalUrl: string;
|
|
41
|
+
clientId: string;
|
|
42
|
+
clientSecret: string;
|
|
43
|
+
remoteBaseUrl: string;
|
|
44
|
+
activeCheckUrl: string;
|
|
45
|
+
}): Promise<ConnectResponse>;
|
|
46
|
+
export declare function cliDisconnect(input: {
|
|
47
|
+
portalUrl: string;
|
|
48
|
+
clientId: string;
|
|
49
|
+
clientSecret: string;
|
|
50
|
+
}): Promise<DisconnectResponse>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { normalizeBaseUrl, portalAuthHeaders } from "./utils.js";
|
|
2
|
+
export async function cliWhoami(input) {
|
|
3
|
+
const base = normalizeBaseUrl(input.portalUrl);
|
|
4
|
+
const rawBody = "";
|
|
5
|
+
const headers = portalAuthHeaders(input.clientId, input.clientSecret, rawBody);
|
|
6
|
+
const res = await fetch(`${base}/api/cli/whoami`, {
|
|
7
|
+
method: "GET",
|
|
8
|
+
headers,
|
|
9
|
+
signal: AbortSignal.timeout(15_000),
|
|
10
|
+
});
|
|
11
|
+
return (await res.json());
|
|
12
|
+
}
|
|
13
|
+
export async function cliConnect(input) {
|
|
14
|
+
const base = normalizeBaseUrl(input.portalUrl);
|
|
15
|
+
const body = JSON.stringify({
|
|
16
|
+
remoteBaseUrl: normalizeBaseUrl(input.remoteBaseUrl),
|
|
17
|
+
activeCheckUrl: normalizeBaseUrl(input.activeCheckUrl),
|
|
18
|
+
enableSync: true,
|
|
19
|
+
enableActiveCheck: true,
|
|
20
|
+
});
|
|
21
|
+
const headers = portalAuthHeaders(input.clientId, input.clientSecret, body);
|
|
22
|
+
const res = await fetch(`${base}/api/cli/connect`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers,
|
|
25
|
+
body,
|
|
26
|
+
signal: AbortSignal.timeout(20_000),
|
|
27
|
+
});
|
|
28
|
+
return (await res.json());
|
|
29
|
+
}
|
|
30
|
+
export async function cliDisconnect(input) {
|
|
31
|
+
const base = normalizeBaseUrl(input.portalUrl);
|
|
32
|
+
const body = "{}";
|
|
33
|
+
const headers = portalAuthHeaders(input.clientId, input.clientSecret, body);
|
|
34
|
+
const res = await fetch(`${base}/api/cli/disconnect`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers,
|
|
37
|
+
body,
|
|
38
|
+
signal: AbortSignal.timeout(15_000),
|
|
39
|
+
});
|
|
40
|
+
return (await res.json());
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type InteractiveAnswers = {
|
|
2
|
+
portalUrl: string;
|
|
3
|
+
clientId: string;
|
|
4
|
+
clientSecret: string;
|
|
5
|
+
confirmApp: boolean;
|
|
6
|
+
remoteBaseUrl: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function promptCredentials(defaults?: {
|
|
9
|
+
portalUrl?: string;
|
|
10
|
+
clientId?: string;
|
|
11
|
+
}): Promise<Pick<InteractiveAnswers, "portalUrl" | "clientId" | "clientSecret">>;
|
|
12
|
+
export declare function promptConfirmApp(app: {
|
|
13
|
+
name: string;
|
|
14
|
+
id: string;
|
|
15
|
+
handle: string;
|
|
16
|
+
}): Promise<boolean>;
|
|
17
|
+
export declare function promptRemoteBaseUrl(initial?: string): Promise<string>;
|
|
18
|
+
export declare function promptOverwrite(fileRel: string): Promise<boolean>;
|
|
19
|
+
export declare function promptConfirmDiscard(): Promise<boolean>;
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import prompts from "prompts";
|
|
2
|
+
export async function promptCredentials(defaults) {
|
|
3
|
+
const res = await prompts([
|
|
4
|
+
{
|
|
5
|
+
type: "text",
|
|
6
|
+
name: "portalUrl",
|
|
7
|
+
message: "Portal base URL",
|
|
8
|
+
initial: defaults?.portalUrl ?? "https://",
|
|
9
|
+
validate: (v) => {
|
|
10
|
+
try {
|
|
11
|
+
const u = new URL(v);
|
|
12
|
+
return u.protocol === "http:" || u.protocol === "https:"
|
|
13
|
+
? true
|
|
14
|
+
: "Must be http(s) URL";
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return "Must be a valid URL";
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
type: "text",
|
|
23
|
+
name: "clientId",
|
|
24
|
+
message: "Client Id (from portal credentials)",
|
|
25
|
+
initial: defaults?.clientId ?? "portal-",
|
|
26
|
+
validate: (v) => (v.trim() ? true : "Required"),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
type: "password",
|
|
30
|
+
name: "clientSecret",
|
|
31
|
+
message: "Client Secret (shown once when generated in portal)",
|
|
32
|
+
validate: (v) => (v.trim() ? true : "Required"),
|
|
33
|
+
},
|
|
34
|
+
], { onCancel: () => process.exit(1) });
|
|
35
|
+
return {
|
|
36
|
+
portalUrl: String(res.portalUrl).trim(),
|
|
37
|
+
clientId: String(res.clientId).trim(),
|
|
38
|
+
clientSecret: String(res.clientSecret).trim(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export async function promptConfirmApp(app) {
|
|
42
|
+
const res = await prompts({
|
|
43
|
+
type: "confirm",
|
|
44
|
+
name: "ok",
|
|
45
|
+
message: `Connect to Shopify app "${app.name}" (${app.handle})?\n App Id: ${app.id}`,
|
|
46
|
+
initial: true,
|
|
47
|
+
}, { onCancel: () => process.exit(1) });
|
|
48
|
+
return Boolean(res.ok);
|
|
49
|
+
}
|
|
50
|
+
export async function promptRemoteBaseUrl(initial) {
|
|
51
|
+
const res = await prompts({
|
|
52
|
+
type: "text",
|
|
53
|
+
name: "remoteBaseUrl",
|
|
54
|
+
message: "Remote Base URL (your deployed Shopify app origin)",
|
|
55
|
+
initial: initial ?? "https://",
|
|
56
|
+
validate: (v) => {
|
|
57
|
+
try {
|
|
58
|
+
const u = new URL(v);
|
|
59
|
+
return u.protocol === "http:" || u.protocol === "https:"
|
|
60
|
+
? true
|
|
61
|
+
: "Must be http(s) URL";
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return "Must be a valid URL";
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}, { onCancel: () => process.exit(1) });
|
|
68
|
+
return String(res.remoteBaseUrl).trim();
|
|
69
|
+
}
|
|
70
|
+
export async function promptOverwrite(fileRel) {
|
|
71
|
+
const res = await prompts({
|
|
72
|
+
type: "confirm",
|
|
73
|
+
name: "ok",
|
|
74
|
+
message: `${fileRel} already exists. Overwrite?`,
|
|
75
|
+
initial: false,
|
|
76
|
+
}, { onCancel: () => process.exit(1) });
|
|
77
|
+
return Boolean(res.ok);
|
|
78
|
+
}
|
|
79
|
+
export async function promptConfirmDiscard() {
|
|
80
|
+
const res = await prompts({
|
|
81
|
+
type: "confirm",
|
|
82
|
+
name: "ok",
|
|
83
|
+
message: "Discard portal scaffold and soft-disconnect from the portal? (credentials are kept)",
|
|
84
|
+
initial: false,
|
|
85
|
+
}, { onCancel: () => process.exit(1) });
|
|
86
|
+
return Boolean(res.ok);
|
|
87
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type ScaffoldResult = {
|
|
2
|
+
written: string[];
|
|
3
|
+
skipped: string[];
|
|
4
|
+
entryServerHint: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Hybrid scaffold: portal/ helpers + thin Remix route stubs.
|
|
8
|
+
*/
|
|
9
|
+
export declare function scaffoldPortalFiles(appRoot: string, opts?: {
|
|
10
|
+
force?: boolean;
|
|
11
|
+
}): Promise<ScaffoldResult>;
|
|
12
|
+
export declare function mergeEnvFile(appRoot: string, vars: Record<string, string>): {
|
|
13
|
+
path: string;
|
|
14
|
+
updated: string[];
|
|
15
|
+
};
|
package/dist/scaffold.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { SCAFFOLD_OWNED_FILES } from "./discard.js";
|
|
4
|
+
import { promptOverwrite } from "./prompts.js";
|
|
5
|
+
import { readTemplate } from "./utils.js";
|
|
6
|
+
async function writeFileSafe(absPath, content, rel, force) {
|
|
7
|
+
const exists = fs.existsSync(absPath);
|
|
8
|
+
if (exists && !force) {
|
|
9
|
+
const ok = await promptOverwrite(rel);
|
|
10
|
+
if (!ok)
|
|
11
|
+
return "skipped";
|
|
12
|
+
}
|
|
13
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
14
|
+
fs.writeFileSync(absPath, content, "utf8");
|
|
15
|
+
return "written";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Hybrid scaffold: portal/ helpers + thin Remix route stubs.
|
|
19
|
+
*/
|
|
20
|
+
export async function scaffoldPortalFiles(appRoot, opts = {}) {
|
|
21
|
+
const force = opts.force ?? false;
|
|
22
|
+
const written = [];
|
|
23
|
+
const skipped = [];
|
|
24
|
+
for (const file of SCAFFOLD_OWNED_FILES) {
|
|
25
|
+
const abs = path.join(appRoot, file.rel);
|
|
26
|
+
const content = readTemplate(file.template);
|
|
27
|
+
const result = await writeFileSafe(abs, content, file.rel, force);
|
|
28
|
+
if (result === "written")
|
|
29
|
+
written.push(file.rel);
|
|
30
|
+
else
|
|
31
|
+
skipped.push(file.rel);
|
|
32
|
+
}
|
|
33
|
+
const entryCandidates = [
|
|
34
|
+
"app/entry.server.tsx",
|
|
35
|
+
"app/entry.server.ts",
|
|
36
|
+
"app/entry.server.jsx",
|
|
37
|
+
];
|
|
38
|
+
let patchedEntry = false;
|
|
39
|
+
for (const rel of entryCandidates) {
|
|
40
|
+
const abs = path.join(appRoot, rel);
|
|
41
|
+
if (!fs.existsSync(abs))
|
|
42
|
+
continue;
|
|
43
|
+
let src = fs.readFileSync(abs, "utf8");
|
|
44
|
+
if (src.includes("startHeartbeatLoop")) {
|
|
45
|
+
skipped.push(`${rel} (heartbeat already wired)`);
|
|
46
|
+
patchedEntry = true;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
const importLine = `import { startHeartbeatLoop } from "../portal/heartbeat.server";\n`;
|
|
50
|
+
const callLine = `\n// Apps Portal heartbeat (scaffolded by @objects/s-portal)\nstartHeartbeatLoop();\n`;
|
|
51
|
+
if (!src.includes("portal/heartbeat.server")) {
|
|
52
|
+
src = importLine + src;
|
|
53
|
+
}
|
|
54
|
+
src = src.trimEnd() + callLine;
|
|
55
|
+
fs.writeFileSync(abs, src, "utf8");
|
|
56
|
+
written.push(`${rel} (heartbeat loop)`);
|
|
57
|
+
patchedEntry = true;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
const entryServerHint = patchedEntry
|
|
61
|
+
? "Heartbeat loop wired into entry.server."
|
|
62
|
+
: [
|
|
63
|
+
"Could not find app/entry.server.tsx — add this manually:",
|
|
64
|
+
' import { startHeartbeatLoop } from "../portal/heartbeat.server";',
|
|
65
|
+
" startHeartbeatLoop();",
|
|
66
|
+
].join("\n");
|
|
67
|
+
return { written, skipped, entryServerHint };
|
|
68
|
+
}
|
|
69
|
+
export function mergeEnvFile(appRoot, vars) {
|
|
70
|
+
const envPath = path.join(appRoot, ".env");
|
|
71
|
+
let existing = "";
|
|
72
|
+
if (fs.existsSync(envPath)) {
|
|
73
|
+
existing = fs.readFileSync(envPath, "utf8");
|
|
74
|
+
}
|
|
75
|
+
const lines = existing ? existing.split(/\r?\n/) : [];
|
|
76
|
+
const updated = [];
|
|
77
|
+
const keyIndex = new Map();
|
|
78
|
+
lines.forEach((line, i) => {
|
|
79
|
+
const m = line.match(/^([A-Z0-9_]+)=/);
|
|
80
|
+
if (m)
|
|
81
|
+
keyIndex.set(m[1], i);
|
|
82
|
+
});
|
|
83
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
84
|
+
const escaped = value.includes("\n") || value.includes(" ")
|
|
85
|
+
? `"${value.replace(/"/g, '\\"')}"`
|
|
86
|
+
: value;
|
|
87
|
+
const line = `${key}=${escaped}`;
|
|
88
|
+
if (keyIndex.has(key)) {
|
|
89
|
+
lines[keyIndex.get(key)] = line;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
if (lines.length && lines[lines.length - 1] !== "")
|
|
93
|
+
lines.push("");
|
|
94
|
+
lines.push(line);
|
|
95
|
+
}
|
|
96
|
+
updated.push(key);
|
|
97
|
+
}
|
|
98
|
+
fs.writeFileSync(envPath, lines.join("\n").replace(/\n+$/, "\n"), "utf8");
|
|
99
|
+
return { path: envPath, updated };
|
|
100
|
+
}
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Resolve templates dir both in src (tsx) and dist (published). */
|
|
2
|
+
export declare function templatesDir(): string;
|
|
3
|
+
export declare function readTemplate(name: string): string;
|
|
4
|
+
export declare function hmacHex(secret: string, payload: string): string;
|
|
5
|
+
export declare function normalizeBaseUrl(url: string): string;
|
|
6
|
+
export declare function portalAuthHeaders(clientId: string, clientSecret: string, rawBody: string): {
|
|
7
|
+
"content-type": string;
|
|
8
|
+
"x-portal-client-id": string;
|
|
9
|
+
"x-portal-timestamp": string;
|
|
10
|
+
"x-portal-signature": string;
|
|
11
|
+
};
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
/** Resolve templates dir both in src (tsx) and dist (published). */
|
|
7
|
+
export function templatesDir() {
|
|
8
|
+
const candidates = [
|
|
9
|
+
path.resolve(__dirname, "../templates"),
|
|
10
|
+
path.resolve(__dirname, "../../templates"),
|
|
11
|
+
];
|
|
12
|
+
for (const dir of candidates) {
|
|
13
|
+
if (fs.existsSync(dir))
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
throw new Error("Could not find @objects/s-portal templates directory.");
|
|
17
|
+
}
|
|
18
|
+
export function readTemplate(name) {
|
|
19
|
+
return fs.readFileSync(path.join(templatesDir(), name), "utf8");
|
|
20
|
+
}
|
|
21
|
+
export function hmacHex(secret, payload) {
|
|
22
|
+
return crypto.createHmac("sha256", secret).update(payload).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
export function normalizeBaseUrl(url) {
|
|
25
|
+
return url.trim().replace(/\/+$/, "");
|
|
26
|
+
}
|
|
27
|
+
export function portalAuthHeaders(clientId, clientSecret, rawBody) {
|
|
28
|
+
const timestamp = Date.now().toString();
|
|
29
|
+
const signature = hmacHex(clientSecret, `${timestamp}.${rawBody}`);
|
|
30
|
+
return {
|
|
31
|
+
"content-type": "application/json",
|
|
32
|
+
"x-portal-client-id": clientId,
|
|
33
|
+
"x-portal-timestamp": timestamp,
|
|
34
|
+
"x-portal-signature": signature,
|
|
35
|
+
};
|
|
36
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@objectsws/s-portal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI to scaffold Apps Portal sync into a Shopify Remix app and register Remote Base URL",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"s-portal": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"templates",
|
|
12
|
+
"README.md",
|
|
13
|
+
"MANUAL_TEST.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -p tsconfig.json",
|
|
17
|
+
"dev": "tsx src/index.ts",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"commander": "^13.1.0",
|
|
25
|
+
"prompts": "^2.4.2"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22",
|
|
29
|
+
"@types/prompts": "^2.4.9",
|
|
30
|
+
"tsx": "^4.21.0",
|
|
31
|
+
"typescript": "^5.9.3"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"shopify",
|
|
35
|
+
"portal",
|
|
36
|
+
"sync",
|
|
37
|
+
"cli"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT"
|
|
40
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Apps Portal sync (scaffolded by `@objects/s-portal`)
|
|
2
|
+
|
|
3
|
+
## Files
|
|
4
|
+
|
|
5
|
+
| Path | Role |
|
|
6
|
+
|------|------|
|
|
7
|
+
| `portal/client.server.ts` | HMAC client — heartbeat + webhook sender |
|
|
8
|
+
| `portal/heartbeat.server.ts` | `startHeartbeatLoop()` for server boot |
|
|
9
|
+
| `portal/notify.server.ts` | Helpers to forward Shopify webhooks to the portal |
|
|
10
|
+
| `app/routes/healthz.ts` | Active check target (`GET /healthz`) |
|
|
11
|
+
| `app/routes/api.portal-sync.tsx` | Token + merchants pull API for the portal |
|
|
12
|
+
|
|
13
|
+
## Wire-up checklist
|
|
14
|
+
|
|
15
|
+
1. **Env** — set by the CLI after connect (`PORTAL_*` vars).
|
|
16
|
+
2. **Heartbeat** — add to the bottom of `app/entry.server.tsx`:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { startHeartbeatLoop } from "../portal/heartbeat.server";
|
|
20
|
+
startHeartbeatLoop();
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
3. **Webhooks** — from your Shopify handlers, call helpers in `portal/notify.server.ts`
|
|
24
|
+
(e.g. `handleAppInstalled`, `handleAppUninstalled`, `handleAppSubscriptionUpdate`).
|
|
25
|
+
|
|
26
|
+
4. **Merchants pull** — implement the TODO query in `app/routes/api.portal-sync.tsx`.
|
|
27
|
+
Each row **must** include `id` and `shop`.
|
|
28
|
+
|
|
29
|
+
5. In the portal UI, use **Verify connection** / **Manual sync** after deploy.
|