@awesomate/hosting-mcp 0.27.0 → 0.30.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/index.js +148 -8
- package/package.json +1 -1
- package/skill/CHANGELOG.json +26 -0
- package/skill/awesomate-app-builder/SKILL.md +46 -2
- package/skill/awesomate-app-builder/references/migrate-from-replit.md +105 -0
- package/skill/awesomate-app-builder/scripts/migrate-check.mjs +176 -0
- package/skill/awesomate-github/SKILL.md +45 -30
- package/skill/awesomate-github/scripts/deploy-key.mjs +159 -0
- package/skill/awesomate-github/scripts/github-setup.mjs +6 -20
- package/skill/awesomate-hosting/SKILL.md +5 -0
- package/skill/awesomate-hosting/scripts/deploy-artifacts.sh +81 -0
package/dist/index.js
CHANGED
|
@@ -40263,6 +40263,12 @@ async function knowledgeSources(config3, args) {
|
|
|
40263
40263
|
if (!args.sourceId || !Array.isArray(args.tags)) return { error: "invalid_request", note: "tag needs sourceId and tags (the full replacement list of free-form tags; collections are unaffected)" };
|
|
40264
40264
|
return await hubPatch(config3, `/api/knowledge/sources/${encodeURIComponent(args.sourceId)}`, { tags: args.tags });
|
|
40265
40265
|
}
|
|
40266
|
+
if (args.action === "rename") {
|
|
40267
|
+
if (!args.sourceId || typeof args.title !== "string" || !args.title.trim()) {
|
|
40268
|
+
return { error: "invalid_request", note: "rename needs sourceId and a non-empty title" };
|
|
40269
|
+
}
|
|
40270
|
+
return await hubPatch(config3, `/api/knowledge/sources/${encodeURIComponent(args.sourceId)}`, { title: args.title.trim() });
|
|
40271
|
+
}
|
|
40266
40272
|
if (args.action === "set_visibility") {
|
|
40267
40273
|
const ids = args.sourceIds?.length ? args.sourceIds : args.sourceId ? [args.sourceId] : [];
|
|
40268
40274
|
if (!ids.length || !args.visibility) return { error: "invalid_request", note: "set_visibility needs sourceId (or sourceIds) and visibility" };
|
|
@@ -40968,6 +40974,12 @@ var TITLES = {
|
|
|
40968
40974
|
awesomate_app_get: "Get app details",
|
|
40969
40975
|
awesomate_app_scaffold: "Get app scaffold guide",
|
|
40970
40976
|
awesomate_app_deploy_info: "Get app deploy briefing",
|
|
40977
|
+
awesomate_app_update: "Link repo to app",
|
|
40978
|
+
awesomate_app_repo_check: "Report repo check",
|
|
40979
|
+
awesomate_app_deploy_event: "Record deploy outcome",
|
|
40980
|
+
awesomate_app_migration_plan: "Get migration checklist",
|
|
40981
|
+
awesomate_app_capacity: "Check hosting capacity",
|
|
40982
|
+
awesomate_app_domain_attach: "Attach hostname to app",
|
|
40971
40983
|
awesomate_app_health: "Check app health",
|
|
40972
40984
|
awesomate_app_run_state: "Start or stop an app environment",
|
|
40973
40985
|
awesomate_app_set_env: "Set app secret",
|
|
@@ -41687,7 +41699,7 @@ server.registerTool(
|
|
|
41687
41699
|
"awesomate_app_get",
|
|
41688
41700
|
{
|
|
41689
41701
|
annotations: annotate("awesomate_app_get", READ_ONLY),
|
|
41690
|
-
description: "Get one app plus its environments (subdomains, ports, db engine/name, deploy + health state). Poll this after awesomate_app_create \u2014 status goes provisioning \u2192 active | failed (read provision_error on failure).",
|
|
41702
|
+
description: "Get one app plus its environments (subdomains, ports, db engine/name, deploy + health state) and any hostnames of the user's own attached to them. Poll this after awesomate_app_create \u2014 status goes provisioning \u2192 active | failed (read provision_error on failure).",
|
|
41691
41703
|
inputSchema: { appId: external_exports.number().int().positive().describe("The app id from awesomate_app_list / _create") }
|
|
41692
41704
|
},
|
|
41693
41705
|
async ({ appId }) => {
|
|
@@ -41724,12 +41736,15 @@ server.registerTool(
|
|
|
41724
41736
|
"awesomate_app_scaffold",
|
|
41725
41737
|
{
|
|
41726
41738
|
annotations: annotate("awesomate_app_scaffold", READ_ONLY),
|
|
41727
|
-
description: "Fetch the app's starter files (its template rendered against the live app metadata \u2014 subdomains, control-plane URL, repo \u2014 with placeholders already substituted). Call after awesomate_app_create reaches status=active: write each returned file into a fresh local project folder at its relative path, then run npm install (Node apps) and follow the bundled CLAUDE.md. This is how the starter code gets onto the user's machine \u2014 don't reconstruct templates by hand.",
|
|
41728
|
-
inputSchema: {
|
|
41739
|
+
description: "Fetch the app's starter files (its template rendered against the live app metadata \u2014 subdomains, control-plane URL, repo \u2014 with placeholders already substituted). Call after awesomate_app_create reaches status=active: write each returned file into a fresh local project folder at its relative path, then run npm install (Node apps) and follow the bundled CLAUDE.md. This is how the starter code gets onto the user's machine \u2014 don't reconstruct templates by hand. mode 'adopt' is for a repo that ALREADY exists (Replit, Lovable, anything): it returns only the plumbing (.github/workflows/deploy.yml, .awesomate.json with the deploy contract, scripts/security-check.mjs, scripts/awesomate-migrate.mjs, AWESOMATE.md) to write into that repo without touching its code. Run awesomate_app_repo_check first so the deploy block is pre-filled from the check.",
|
|
41740
|
+
inputSchema: {
|
|
41741
|
+
appId: external_exports.number().int().positive().describe("The app id from awesomate_app_create / _list"),
|
|
41742
|
+
mode: external_exports.enum(["full", "adopt"]).default("full").describe("'full' = the whole starter for a new app; 'adopt' = plumbing only for an existing repo")
|
|
41743
|
+
}
|
|
41729
41744
|
},
|
|
41730
|
-
async ({ appId }) => {
|
|
41745
|
+
async ({ appId, mode }) => {
|
|
41731
41746
|
try {
|
|
41732
|
-
return textResult(await hubGet(requireConfig(), `/api/my-apps/apps/${appId}/scaffold`));
|
|
41747
|
+
return textResult(await hubGet(requireConfig(), `/api/my-apps/apps/${appId}/scaffold${mode === "adopt" ? "?mode=adopt" : ""}`));
|
|
41733
41748
|
} catch (err) {
|
|
41734
41749
|
return errorResult(err);
|
|
41735
41750
|
}
|
|
@@ -41739,7 +41754,7 @@ server.registerTool(
|
|
|
41739
41754
|
"awesomate_app_deploy_info",
|
|
41740
41755
|
{
|
|
41741
41756
|
annotations: annotate("awesomate_app_deploy_info", READ_ONLY),
|
|
41742
|
-
description: "READ-ONLY deploy briefing \u2014 this never deploys anything (deploys happen via git push). Reports how to deploy an app, its per-env targets, last-deploy/health state, and how to promote (dev\u2192staging\u2192main) or roll back (git revert + push). Node apps deploy via git push (dev/staging/main \u2192 GitHub Actions \u2192 cPanel).
|
|
41757
|
+
description: "READ-ONLY deploy briefing \u2014 this never deploys anything (deploys happen via git push). Reports how to deploy an app, its per-env targets, last-deploy/health state, whether a deploy key is registered, and how to promote (dev\u2192staging\u2192main) or roll back (git revert + push). Node apps deploy via git push (dev/staging/main \u2192 GitHub Actions builds on its runner \u2192 artefacts shipped to cPanel; nothing builds on the account). First-time wiring is awesomate-github/scripts/deploy-key.mjs <appId>, no ticket needed. For an existing repo being moved from Replit or Lovable, start with awesomate_app_migration_plan.",
|
|
41743
41758
|
inputSchema: { appId: external_exports.number().int().positive().describe("The app id") }
|
|
41744
41759
|
},
|
|
41745
41760
|
async ({ appId }) => {
|
|
@@ -41750,6 +41765,130 @@ server.registerTool(
|
|
|
41750
41765
|
}
|
|
41751
41766
|
}
|
|
41752
41767
|
);
|
|
41768
|
+
server.registerTool(
|
|
41769
|
+
"awesomate_app_update",
|
|
41770
|
+
{
|
|
41771
|
+
annotations: annotate("awesomate_app_update", MUTATING),
|
|
41772
|
+
description: "Link an app to the GitHub repo it deploys from and record where the code came from. Call it as soon as the repo is known (owner/repo) for an app being moved from Replit, Lovable or any existing codebase; deploy-key.mjs also calls it for you when it sets the repo secrets. autoTickets:false turns off the hub's automatic support tickets for this app (they are on by default and open a Desk ticket when a rule fires: a committed secret, a blocker reported three times, a deploy failing three times in a day).",
|
|
41773
|
+
inputSchema: {
|
|
41774
|
+
appId: external_exports.number().int().positive().describe("The app id"),
|
|
41775
|
+
githubRepo: external_exports.string().min(3).max(255).optional().describe("'owner/repo' (a github.com URL is accepted and trimmed)"),
|
|
41776
|
+
sourcePlatform: external_exports.enum(["scaffold", "replit", "lovable", "other"]).optional().describe("Where the code came from"),
|
|
41777
|
+
deployKeyFingerprint: external_exports.string().min(1).max(128).optional().describe("Set by deploy-key.mjs; the fingerprint of the per-app deploy key"),
|
|
41778
|
+
readyPath: external_exports.string().regex(/^\/[A-Za-z0-9_\-./]{0,120}$/).optional().describe("Readiness route the health checks probe, default /api/ready"),
|
|
41779
|
+
autoTickets: external_exports.boolean().optional().describe("Whether the hub may open support tickets about this app automatically (default true)")
|
|
41780
|
+
}
|
|
41781
|
+
},
|
|
41782
|
+
async ({ appId, ...body }) => {
|
|
41783
|
+
try {
|
|
41784
|
+
return textResult(await hubPatch(requireConfig(), `/api/my-apps/apps/${appId}`, body));
|
|
41785
|
+
} catch (err) {
|
|
41786
|
+
return errorResult(err);
|
|
41787
|
+
}
|
|
41788
|
+
}
|
|
41789
|
+
);
|
|
41790
|
+
server.registerTool(
|
|
41791
|
+
"awesomate_app_repo_check",
|
|
41792
|
+
{
|
|
41793
|
+
annotations: annotate("awesomate_app_repo_check", MUTATING),
|
|
41794
|
+
description: "Report the JSON produced by `node ~/.claude/skills/awesomate-app-builder/scripts/migrate-check.mjs [--fix]` for the repo this app deploys from. The hub stores it, updates the migration plan (repo_checked becomes done when no blockers remain), pre-fills the deploy block that awesomate_app_scaffold mode 'adopt' returns, and applies its ticket rules: a committed secret opens a Desk ticket at once; the same blocker reported three times opens one; otherwise nothing is sent. Fix blockers in the code, re-run the script, report again. Never paste source code into this call; the report carries rule ids, file paths and one-line details only.",
|
|
41795
|
+
inputSchema: {
|
|
41796
|
+
appId: external_exports.number().int().positive().describe("The app id"),
|
|
41797
|
+
report: external_exports.object({
|
|
41798
|
+
version: external_exports.number().optional(),
|
|
41799
|
+
platform: external_exports.string().optional(),
|
|
41800
|
+
findings: external_exports.array(external_exports.object({ id: external_exports.string(), severity: external_exports.enum(["info", "warn", "blocker"]), title: external_exports.string().optional(), detail: external_exports.string().optional(), file: external_exports.string().optional(), fix: external_exports.string().optional() })).max(200),
|
|
41801
|
+
detected: external_exports.record(external_exports.unknown()).optional(),
|
|
41802
|
+
deploy: external_exports.record(external_exports.unknown()).optional()
|
|
41803
|
+
}).passthrough().describe("The script output, passed through unchanged")
|
|
41804
|
+
}
|
|
41805
|
+
},
|
|
41806
|
+
async ({ appId, report }) => {
|
|
41807
|
+
try {
|
|
41808
|
+
return textResult(await hubPost(requireConfig(), `/api/my-apps/apps/${appId}/repo-check`, { report }));
|
|
41809
|
+
} catch (err) {
|
|
41810
|
+
return errorResult(err);
|
|
41811
|
+
}
|
|
41812
|
+
}
|
|
41813
|
+
);
|
|
41814
|
+
server.registerTool(
|
|
41815
|
+
"awesomate_app_deploy_event",
|
|
41816
|
+
{
|
|
41817
|
+
annotations: annotate("awesomate_app_deploy_event", MUTATING),
|
|
41818
|
+
description: "Record the outcome of a deploy to one environment, after `gh run watch` finishes or the manual artefact deploy exits. Pass outcome 'failure' with the failing step name (from gh run view --log-failed) or 'success' with the commit sha. The hub updates the plan and, after three failures on one env inside a day, opens a support ticket itself and tells you so; do not push again blindly and do not raise the ticket yourself.",
|
|
41819
|
+
inputSchema: {
|
|
41820
|
+
appId: external_exports.number().int().positive().describe("The app id"),
|
|
41821
|
+
env: external_exports.enum(["dev", "staging", "prod"]).describe("Which environment the deploy targeted"),
|
|
41822
|
+
outcome: external_exports.enum(["success", "failure"]),
|
|
41823
|
+
source: external_exports.enum(["workflow", "healthcheck", "manual"]).default("workflow").describe("'workflow' = GitHub Actions, 'manual' = deploy-artifacts.sh"),
|
|
41824
|
+
step: external_exports.string().max(64).optional().describe('Failing workflow step, e.g. "Readiness check"'),
|
|
41825
|
+
sha: external_exports.string().regex(/^[0-9a-f]{40}$/i).optional().describe("Full commit sha that was deployed"),
|
|
41826
|
+
runUrl: external_exports.string().url().max(512).optional().describe("GitHub Actions run URL"),
|
|
41827
|
+
detail: external_exports.string().max(500).optional().describe("One line on what failed; no logs, no secrets")
|
|
41828
|
+
}
|
|
41829
|
+
},
|
|
41830
|
+
async ({ appId, ...body }) => {
|
|
41831
|
+
try {
|
|
41832
|
+
return textResult(await hubPost(requireConfig(), `/api/my-apps/apps/${appId}/deploy-events`, body));
|
|
41833
|
+
} catch (err) {
|
|
41834
|
+
return errorResult(err);
|
|
41835
|
+
}
|
|
41836
|
+
}
|
|
41837
|
+
);
|
|
41838
|
+
server.registerTool(
|
|
41839
|
+
"awesomate_app_migration_plan",
|
|
41840
|
+
{
|
|
41841
|
+
annotations: annotate("awesomate_app_migration_plan", READ_ONLY),
|
|
41842
|
+
description: "The hub-computed checklist for getting an existing app (Replit, Lovable, any repo) running on Awesomate: ordered steps, each done / todo / blocked / not_applicable / unavailable, with the exact tool or script that completes it, and any support ticket the hub has already opened about it. Call it at the start of a migration session and after each step; follow `next`. The capacity step reads the account's real limits (awesomate_app_capacity) and the hostname step attaches the user's own subdomain (awesomate_app_domain_attach).",
|
|
41843
|
+
inputSchema: { appId: external_exports.number().int().positive().describe("The app id") }
|
|
41844
|
+
},
|
|
41845
|
+
async ({ appId }) => {
|
|
41846
|
+
try {
|
|
41847
|
+
return textResult(await hubGet(requireConfig(), `/api/my-apps/apps/${appId}/migration-plan`));
|
|
41848
|
+
} catch (err) {
|
|
41849
|
+
return errorResult(err);
|
|
41850
|
+
}
|
|
41851
|
+
}
|
|
41852
|
+
);
|
|
41853
|
+
server.registerTool(
|
|
41854
|
+
"awesomate_app_capacity",
|
|
41855
|
+
{
|
|
41856
|
+
annotations: annotate("awesomate_app_capacity", MUTATING),
|
|
41857
|
+
description: "Will this app fit on the user's hosting account? Reads the account's live limits and last-day peaks (memory ceiling shared by every environment and the website, task limit where a Node process costs about 11, disk, database sizes, any install or build running on the account, pm2 restart loops) and returns a verdict: ok, warn, blocked or unknown, with reasons and headroom. Call it before the first deploy and again before prod. With fix:true it raises the account's task and memory limits to the platform floors (raise-only, never lowers) when that is what blocks; anything left after that is for Awesomate and the hub opens a support ticket itself and shows it here; do not raise one yourself. Runs a few seconds of server reads; do not poll it.",
|
|
41858
|
+
inputSchema: {
|
|
41859
|
+
appId: external_exports.number().int().positive().describe("The app id"),
|
|
41860
|
+
fix: external_exports.boolean().default(false).describe("Raise the account floors if that is what blocks (Support Plus and above)")
|
|
41861
|
+
}
|
|
41862
|
+
},
|
|
41863
|
+
async ({ appId, fix }) => {
|
|
41864
|
+
try {
|
|
41865
|
+
const cfg = requireConfig();
|
|
41866
|
+
return textResult(fix ? await hubPost(cfg, `/api/my-apps/apps/${appId}/capacity/fix`, {}) : await hubGet(cfg, `/api/my-apps/apps/${appId}/capacity`));
|
|
41867
|
+
} catch (err) {
|
|
41868
|
+
return errorResult(err);
|
|
41869
|
+
}
|
|
41870
|
+
}
|
|
41871
|
+
);
|
|
41872
|
+
server.registerTool(
|
|
41873
|
+
"awesomate_app_domain_attach",
|
|
41874
|
+
{
|
|
41875
|
+
annotations: annotate("awesomate_app_domain_attach", MUTATING),
|
|
41876
|
+
description: "Put the user's own hostname on an app environment (default prod), so the switch from their old platform is a DNS change. The hostname must be a SUBDOMAIN of a domain already on their hosting account (app.theirdomain.com); the apex and www stay with whatever serves them today, usually their website. Creates the cPanel subdomain and the reverse proxy for that environment, queues the certificate, and returns the exact DNS record to set plus whether it already points at Awesomate. Calling it again for a hostname already attached is a cheap DNS re-check (no reconfiguration, no Apache reload); pass reattach:true only to rewrite the plumbing. awesomate_app_get also re-checks DNS for attached hostnames. Node apps only; a domain not yet on the account needs awesomate_domain_add first. A 503 hostname_attach_disabled means Awesomate has not switched this on yet: tell the user, keep prod on its Awesomate address, do not retry.",
|
|
41877
|
+
inputSchema: {
|
|
41878
|
+
appId: external_exports.number().int().positive().describe("The app id"),
|
|
41879
|
+
hostname: external_exports.string().min(4).max(253).describe("e.g. app.careconnect-ai.com.au"),
|
|
41880
|
+
env: external_exports.enum(["dev", "staging", "prod"]).default("prod"),
|
|
41881
|
+
reattach: external_exports.boolean().default(false).describe("Rewrite the subdomain plumbing even though the hostname is already attached")
|
|
41882
|
+
}
|
|
41883
|
+
},
|
|
41884
|
+
async ({ appId, ...body }) => {
|
|
41885
|
+
try {
|
|
41886
|
+
return textResult(await hubPost(requireConfig(), `/api/my-apps/apps/${appId}/domains`, body));
|
|
41887
|
+
} catch (err) {
|
|
41888
|
+
return errorResult(err);
|
|
41889
|
+
}
|
|
41890
|
+
}
|
|
41891
|
+
);
|
|
41753
41892
|
server.registerTool(
|
|
41754
41893
|
"awesomate_app_set_env",
|
|
41755
41894
|
{
|
|
@@ -41893,9 +42032,9 @@ server.registerTool(
|
|
|
41893
42032
|
"awesomate_knowledge_sources",
|
|
41894
42033
|
{
|
|
41895
42034
|
annotations: annotate("awesomate_knowledge_sources", MUTATING),
|
|
41896
|
-
description: "The knowledge base's content sources \u2014 the LIBRARY. action 'search' {q?, kind?, visibility?, tags?, collectionIds?, cursor?, limit?} \u2014 find sources by title/tag substring and metadata filters (instant, free; for CONTENT search use awesomate_knowledge_search); each row carries visibility (private|internal|public \u2014 who may retrieve it, ENFORCED), tags and collections. 'tag' {sourceId, tags} \u2014 REPLACE a source's free-form tags. 'set_visibility' {sourceId|sourceIds, visibility, confirm?} \u2014 change who may retrieve the source(s); 'public' is irreversible once fetched, so it needs the user's explicit agreement and their account slug as confirm. 'move' {sourceId|sourceIds, collectionIds?, removeCollectionIds?} \u2014 add to / remove from collections. 'sync_rules' / 'sync_rule_create' {pathPrefix, includeGlobs?, visibility?, tags?, collectionIds?, onFileRemoved?} / 'sync_rule_delete' {ruleId} / 'sync_run' / 'sync_state' \u2014 keep a folder of the account's File Manager (their n8n file system: public/, private/, temp/) synced into the knowledge base, so files their workflows write become searchable; a public rule needs confirm. 'list' \u2014 ONE page of sources, most recently ingested first (default 50, max 200 via limit): read `page.has_more`/`next_cursor` and pass cursor to continue \u2014 a page is never the whole library. 'summary' \u2014 exact whole-library counts {total, by_kind, chunks, indexed_chunks, failed_sources, failed_jobs_7d}: use THIS to say what the knowledge base contains, and if failed_jobs_7d > 0 say so \u2014 those ingests are missing from every other count. 'jobs' \u2014 ingest job statuses (optional status filter: queued|running|succeeded|failed). 'add' \u2014 ingest a public page {url} or a whole site {sitemap, since?}, optionally with visibility (default private), tags and collectionIds; ALWAYS get explicit approval first (ingest costs money and counts against quota), for a local FILE on the user's machine use awesomate_knowledge_upload instead (it streams the file from disk; this tool takes URLs only). A pack_required response means the allowance is exhausted: NOTHING was purchased \u2014 present the pack price (1 credit = $100) and let the user buy from the hub if they want it. 'remove' {sourceId} \u2014 deletes the source AND its indexed content; explicit approval required.",
|
|
42035
|
+
description: "The knowledge base's content sources \u2014 the LIBRARY. action 'search' {q?, kind?, visibility?, tags?, collectionIds?, cursor?, limit?} \u2014 find sources by title/tag substring and metadata filters (instant, free; for CONTENT search use awesomate_knowledge_search); each row carries visibility (private|internal|public \u2014 who may retrieve it, ENFORCED), tags and collections. 'tag' {sourceId, tags} \u2014 REPLACE a source's free-form tags. 'rename' {sourceId, title} \u2014 set the source's display TITLE, which is what every citation shows. An UPLOADED file is titled from its FILENAME slugified into title case (Nasa Coverage \u2026 Reagan S Address), because there is nowhere to put a title at ingest; this is the only way to fix it. Rename AFTER the ingest job succeeds. 'set_visibility' {sourceId|sourceIds, visibility, confirm?} \u2014 change who may retrieve the source(s); 'public' is irreversible once fetched, so it needs the user's explicit agreement and their account slug as confirm. 'move' {sourceId|sourceIds, collectionIds?, removeCollectionIds?} \u2014 add to / remove from collections. 'sync_rules' / 'sync_rule_create' {pathPrefix, includeGlobs?, visibility?, tags?, collectionIds?, onFileRemoved?} / 'sync_rule_delete' {ruleId} / 'sync_run' / 'sync_state' \u2014 keep a folder of the account's File Manager (their n8n file system: public/, private/, temp/) synced into the knowledge base, so files their workflows write become searchable; a public rule needs confirm. 'list' \u2014 ONE page of sources, most recently ingested first (default 50, max 200 via limit): read `page.has_more`/`next_cursor` and pass cursor to continue \u2014 a page is never the whole library. 'summary' \u2014 exact whole-library counts {total, by_kind, chunks, indexed_chunks, failed_sources, failed_jobs_7d}: use THIS to say what the knowledge base contains, and if failed_jobs_7d > 0 say so \u2014 those ingests are missing from every other count. 'jobs' \u2014 ingest job statuses (optional status filter: queued|running|succeeded|failed). 'add' \u2014 ingest a public page {url} or a whole site {sitemap, since?}, optionally with visibility (default private), tags and collectionIds; ALWAYS get explicit approval first (ingest costs money and counts against quota), for a local FILE on the user's machine use awesomate_knowledge_upload instead (it streams the file from disk; this tool takes URLs only). A pack_required response means the allowance is exhausted: NOTHING was purchased \u2014 present the pack price (1 credit = $100) and let the user buy from the hub if they want it. 'remove' {sourceId} \u2014 deletes the source AND its indexed content; explicit approval required.",
|
|
41897
42036
|
inputSchema: {
|
|
41898
|
-
action: external_exports.enum(["list", "summary", "add", "remove", "jobs", "search", "tag", "set_visibility", "move", "sync_rules", "sync_rule_create", "sync_rule_delete", "sync_run", "sync_state"]),
|
|
42037
|
+
action: external_exports.enum(["list", "summary", "add", "remove", "jobs", "search", "tag", "rename", "set_visibility", "move", "sync_rules", "sync_rule_create", "sync_rule_delete", "sync_run", "sync_state"]),
|
|
41899
42038
|
pathPrefix: external_exports.string().max(512).optional().describe("sync_rule_create: a File Manager folder to keep synced, e.g. 'public/reports'"),
|
|
41900
42039
|
includeGlobs: external_exports.array(external_exports.string().max(120)).max(20).optional().describe("sync_rule_create: only files matching these globs (relative to the folder), e.g. ['**/*.pdf']"),
|
|
41901
42040
|
onFileRemoved: external_exports.enum(["keep", "demote_to_private", "delete"]).optional().describe("sync_rule_create: what happens to the knowledge source when the file is removed (default demote_to_private for internal/public rules, keep for private)"),
|
|
@@ -41904,6 +42043,7 @@ server.registerTool(
|
|
|
41904
42043
|
kind: external_exports.string().max(32).optional().describe("search: source kind filter (web, document, video, audio, image, book, dataset)"),
|
|
41905
42044
|
visibility: external_exports.enum(["private", "internal", "public"]).optional().describe("add: the audience the new source is cleared for (default private); set_visibility: the level to apply; search: filter"),
|
|
41906
42045
|
tags: external_exports.array(external_exports.string().max(64)).max(64).optional().describe("add: free-form tags; tag: the REPLACEMENT list; search: filter (AND)"),
|
|
42046
|
+
title: external_exports.string().min(1).max(300).optional().describe("rename: the source's display title, shown in every citation"),
|
|
41907
42047
|
collectionIds: external_exports.array(external_exports.string().max(64)).max(16).optional().describe("add: put the source in these collections; move: add to; search: filter"),
|
|
41908
42048
|
removeCollectionIds: external_exports.array(external_exports.string().max(64)).max(16).optional().describe("move: remove from these collections"),
|
|
41909
42049
|
sourceIds: external_exports.array(external_exports.string().max(300)).max(200).optional().describe("set_visibility/move: several sources at once"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awesomate/hosting-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Awesomate MCP server — lets Claude manage your Awesomate WordPress hosting, plan, limits, n8n automations, and build Node/static apps + databases",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
package/skill/CHANGELOG.json
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"versions": [
|
|
3
|
+
{
|
|
4
|
+
"version": "0.30.0",
|
|
5
|
+
"highlights": [
|
|
6
|
+
"Put your own hostname on your app: awesomate_app_domain_attach sets up app.yourdomain.com on production, queues the certificate and tells you the exact DNS record to create, so moving from Replit or Lovable ends in a DNS change rather than a new address",
|
|
7
|
+
"The migration checklist now carries the hostname and DNS steps through to done"
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"version": "0.29.0",
|
|
12
|
+
"highlights": [
|
|
13
|
+
"Ask Claude whether your app will fit before you deploy: awesomate_app_capacity reads your hosting account's real memory and process limits, last-day peaks, disk and database sizes, and says ok, warn or blocked with the reasons",
|
|
14
|
+
"When the account's limits are the problem, fix:true raises them to the platform floors on the spot; anything that still blocks becomes a support ticket Awesomate opens for you",
|
|
15
|
+
"Your production app is now probed every five minutes; if it stops answering for a quarter of an hour after having been healthy, Awesomate is told and a ticket appears in your plan",
|
|
16
|
+
"Every account that hosts an app is swept nightly so an old process limit cannot sit unnoticed"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"version": "0.28.0",
|
|
21
|
+
"highlights": [
|
|
22
|
+
"Moving an app from Replit or Lovable is now a guided checklist: awesomate_app_migration_plan tells Claude exactly what is done, what is next and which tool finishes it, and no support ticket is needed to connect deploys",
|
|
23
|
+
"Push-to-deploy is set up from your own machine: deploy-key.mjs makes a key just for your app, authorises it on your hosting account and sets the three repo secrets",
|
|
24
|
+
"A pre-deploy repo check catches the Replit lockfile that points at a private mirror, the Neon-only database driver, a hardcoded port and a missing readiness route before anything reaches the server",
|
|
25
|
+
"The deploy now builds on GitHub and ships only the built files; nothing builds on your hosting account, which is what made deploys die with Killed",
|
|
26
|
+
"If the check finds a committed secret or a deploy keeps failing, Awesomate opens a support ticket for you and the plan shows the link"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
3
29
|
{
|
|
4
30
|
"version": "0.20.7",
|
|
5
31
|
"highlights": [
|
|
@@ -58,8 +58,8 @@ decision brain. The short version, matched to what they say:
|
|
|
58
58
|
single subdomain — there's no dev copy to review on, so every change is
|
|
59
59
|
live the moment it's published.
|
|
60
60
|
- **No deploy workflow.** The static template is just the page files, so
|
|
61
|
-
pushing to GitHub does not publish it
|
|
62
|
-
|
|
61
|
+
pushing to GitHub does not publish it; the files are copied to the site's
|
|
62
|
+
document root over the account SSH (awesomate-hosting `ssh-connect.sh`).
|
|
63
63
|
|
|
64
64
|
If the page needs a webhook URL, a secret, or a place to test before it
|
|
65
65
|
goes live, it's a **Node app**. Say that in one line and move on.
|
|
@@ -182,6 +182,47 @@ When the need is "remember/track a list an automation uses" rather than an
|
|
|
182
182
|
app with a UI, an n8n data table (awesomate-n8n skill) beats building an app
|
|
183
183
|
— no hosting slot, no deploy, queryable from workflows directly.
|
|
184
184
|
|
|
185
|
+
## 2b. Bringing an existing app (Replit, Lovable, any repo)
|
|
186
|
+
|
|
187
|
+
Many users arrive with a working app and their code already on GitHub. Do
|
|
188
|
+
not rebuild it. Read [references/migrate-from-replit.md](references/migrate-from-replit.md)
|
|
189
|
+
and let the hub drive: **`awesomate_app_migration_plan` returns the ordered
|
|
190
|
+
checklist for that app with each step done / todo / blocked and the tool or
|
|
191
|
+
script that finishes it. Call it first, follow `next`, call it again after
|
|
192
|
+
every step.** The short shape:
|
|
193
|
+
|
|
194
|
+
1. `awesomate_app_create` (Node, Postgres) if the app does not exist yet.
|
|
195
|
+
2. `awesomate_app_update` with the `owner/repo` and `sourcePlatform`.
|
|
196
|
+
3. `node ~/.claude/skills/awesomate-app-builder/scripts/migrate-check.mjs --fix`
|
|
197
|
+
in the clone, then `awesomate_app_repo_check` with its JSON. Fix blockers
|
|
198
|
+
in the code, re-run, re-report until none remain.
|
|
199
|
+
4. `awesomate_app_scaffold` with `mode: 'adopt'`: write the returned plumbing
|
|
200
|
+
into the repo (workflow, `.awesomate.json`, security gate, migrate runner,
|
|
201
|
+
`AWESOMATE.md`). Touch nothing else.
|
|
202
|
+
5. `node ~/.claude/skills/awesomate-github/scripts/deploy-key.mjs <appId>`.
|
|
203
|
+
6. `awesomate_app_set_env` for each secret per environment.
|
|
204
|
+
6b. `awesomate_app_capacity` before the first deploy: it reads the account's
|
|
205
|
+
real memory and task limits and last-day peaks. `blocked` with an
|
|
206
|
+
auto-fixable reason → call again with `fix: true`; anything left is for
|
|
207
|
+
Awesomate and the hub has already opened the ticket.
|
|
208
|
+
7. Push `dev`, `gh run watch`, `awesomate_app_health`, then
|
|
209
|
+
`awesomate_app_deploy_event` with the outcome.
|
|
210
|
+
8. Data into staging, rehearse, then prod. Stop idle environments after.
|
|
211
|
+
9. `awesomate_app_domain_attach` puts their own subdomain on prod and returns
|
|
212
|
+
the DNS record; the cutover is that DNS change.
|
|
213
|
+
|
|
214
|
+
**Never build on the hosting account** (no `npm ci` without `--omit=dev`, no
|
|
215
|
+
`npm run build`, no `vite build` over SSH). It shares one memory ceiling with
|
|
216
|
+
the user's other environments and website, and a build there dies with
|
|
217
|
+
"Killed". GitHub Actions builds; `awesomate-hosting/scripts/deploy-artifacts.sh`
|
|
218
|
+
is the manual fallback and builds on the user's machine.
|
|
219
|
+
|
|
220
|
+
**Never raise a support ticket for these problems yourself.** When the repo
|
|
221
|
+
check finds a committed secret, a blocker is reported three times, or a
|
|
222
|
+
deploy fails three times in a day, the hub opens the ticket and the plan
|
|
223
|
+
shows its link. Say "Awesomate has been notified and will pick this up" and
|
|
224
|
+
keep going on what you can fix.
|
|
225
|
+
|
|
185
226
|
## 3. Hard rules
|
|
186
227
|
|
|
187
228
|
- Non-technical user: explain in plain words, one or two short questions at a
|
|
@@ -201,5 +242,8 @@ app with a UI, an n8n data table (awesomate-n8n skill) beats building an app
|
|
|
201
242
|
The honest options are an upgrade or asking support (awesomate-support
|
|
202
243
|
skill) to remove an app.
|
|
203
244
|
- Never invent that a capability exists — read `awesomate_app_context` first.
|
|
245
|
+
- Never build on the hosting account, and never raise a support ticket for a
|
|
246
|
+
repo or deploy problem yourself: the hub does both the building (via
|
|
247
|
+
GitHub Actions) and the ticketing (via its rules). See §2b.
|
|
204
248
|
- Never print a secret or commit a `.env` (the credentials + github skills
|
|
205
249
|
enforce this).
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Moving an app from Replit (or Lovable) to Awesomate hosting
|
|
2
|
+
|
|
3
|
+
The user has a working app somewhere else and their code on their own GitHub.
|
|
4
|
+
The job is to make it run on their Awesomate account in three environments,
|
|
5
|
+
move the data, and switch their address, without anyone at Awesomate doing a
|
|
6
|
+
step by hand. **The hub tells you what is left**: call
|
|
7
|
+
`awesomate_app_migration_plan` at the start of every session and after every
|
|
8
|
+
step; it returns ordered steps with `done` / `todo` / `blocked` and the tool
|
|
9
|
+
or script that completes each one. Follow it rather than this page from
|
|
10
|
+
memory.
|
|
11
|
+
|
|
12
|
+
## Rules that do not bend
|
|
13
|
+
|
|
14
|
+
- **Never build on the hosting account.** No `npm ci` (without `--omit=dev`),
|
|
15
|
+
no `npm run build`, no `vite build` over SSH. The account has one memory
|
|
16
|
+
ceiling shared by every environment and the user's website; a build there
|
|
17
|
+
is killed mid-run with no useful error. GitHub Actions builds and ships
|
|
18
|
+
artefacts; `awesomate-hosting/scripts/deploy-artifacts.sh` is the manual
|
|
19
|
+
fallback and builds on the user's machine.
|
|
20
|
+
- **Never raise a support ticket for these problems yourself.** The hub opens
|
|
21
|
+
one when a rule fires (a secret in the repo, a blocker reported three times,
|
|
22
|
+
a deploy that fails three times in a day) and the plan shows its link. Tell
|
|
23
|
+
the user "Awesomate has been notified and will pick this up" and keep going
|
|
24
|
+
on what you can fix.
|
|
25
|
+
- **Dev first, then staging with real data, then prod.** Never push `main`
|
|
26
|
+
before the staging rehearsal passes.
|
|
27
|
+
|
|
28
|
+
## The sequence (what the plan will walk you through)
|
|
29
|
+
|
|
30
|
+
1. **Provision the app** if it does not exist: `awesomate_app_create` with
|
|
31
|
+
`kind: 'node'`, template `node-crud-postgres` (Postgres), name from the
|
|
32
|
+
user. Three environments with their own database and `.env` appear.
|
|
33
|
+
2. **Link the repo**: `awesomate_app_update { githubRepo: 'owner/repo',
|
|
34
|
+
sourcePlatform: 'replit' }`.
|
|
35
|
+
3. **Check the repo**: run
|
|
36
|
+
`node ~/.claude/skills/awesomate-app-builder/scripts/migrate-check.mjs --fix`
|
|
37
|
+
in the clone, then post the JSON with `awesomate_app_repo_check`. Fix
|
|
38
|
+
every `blocker` in the code, re-run, re-post. The common Replit ones:
|
|
39
|
+
- `lockfile_private_registry`: the lockfile resolves packages from
|
|
40
|
+
Replit's mirror. `--fix` regenerates it; commit the result. This is why
|
|
41
|
+
`npm ci` hangs for 14 minutes and dies with an empty log.
|
|
42
|
+
- `neon_serverless_driver`: `@neondatabase/serverless` only talks to
|
|
43
|
+
Neon. Switch to `pg` with `drizzle-orm/node-postgres`.
|
|
44
|
+
- `ready_route_missing`: add `/api/ready` (200 only when `select 1`
|
|
45
|
+
succeeds, else 503). Snippet in the repo's `AWESOMATE.md` after step 4.
|
|
46
|
+
- `port_hardcoded`: `app.listen(Number(process.env.PORT))`.
|
|
47
|
+
- `drizzle_no_generated_sql` (warn): add `drizzle-kit generate` to the
|
|
48
|
+
build, commit `drizzle/`, use the shipped migrate runner. Never `push`.
|
|
49
|
+
- `replit_dev_deps`, `replit_env_reads`, `replit_auth` (warn): remove
|
|
50
|
+
the `@replit/*` plugins, `REPL_ID` reads, and replace Replit Auth.
|
|
51
|
+
4. **Add the plumbing**: `awesomate_app_scaffold { appId, mode: 'adopt' }`
|
|
52
|
+
returns only `.github/workflows/deploy.yml`, `.awesomate.json`,
|
|
53
|
+
`scripts/security-check.mjs`, `scripts/awesomate-migrate.mjs` and
|
|
54
|
+
`AWESOMATE.md`. Write them into the repo; touch nothing else. Then edit
|
|
55
|
+
the `deploy` block in `.awesomate.json` to match what the check script
|
|
56
|
+
suggested under `deploy` (artifacts, migrate).
|
|
57
|
+
5. **Deploy key**: `node ~/.claude/skills/awesomate-github/scripts/deploy-key.mjs <appId>`
|
|
58
|
+
in the repo. It mints a key for this app, authorises it on the user's
|
|
59
|
+
account, sets the three repo secrets, and records the repo on the hub.
|
|
60
|
+
6. **Secrets**: `awesomate_app_set_env` per key per environment. `PORT` and
|
|
61
|
+
`DATABASE_URL` are already there. Replit Secrets map one to one; ask the
|
|
62
|
+
user for values through the awesomate-credentials skill, never in chat.
|
|
63
|
+
6b. **Capacity**: `awesomate_app_capacity` before the first deploy and again
|
|
64
|
+
before prod. It reports the account's memory ceiling (shared by every
|
|
65
|
+
environment and the website), the task limit (a Node process costs about
|
|
66
|
+
11), last-day peaks and faults, disk and database sizes, and whether a
|
|
67
|
+
build is running on the account. `blocked` on `nproc_below_floor` or
|
|
68
|
+
`pmem_below_floor` → call again with `fix: true`. Faults, low disk or a
|
|
69
|
+
restart loop are for Awesomate: the hub opens the ticket; tell the user.
|
|
70
|
+
7. **Dev**: create and push `dev`; `gh run watch`; `awesomate_app_health`.
|
|
71
|
+
Report the outcome with `awesomate_app_deploy_event` (success or failure,
|
|
72
|
+
with the failing step) so the plan and the stuck-deploy rule see it.
|
|
73
|
+
8. **Data into staging**: on the user's machine,
|
|
74
|
+
`pg_dump --no-owner --no-acl "<old DATABASE_URL>" | gzip > dump.sql.gz`,
|
|
75
|
+
then over their jailed SSH (`awesomate-hosting/scripts/ssh-connect.sh`):
|
|
76
|
+
`gunzip -c dump.sql.gz | ssh ... 'set -a; . ~/apps/<slug>-staging/.env; psql "$DATABASE_URL"'`.
|
|
77
|
+
Push `staging`, exercise the real flows on the staging URL.
|
|
78
|
+
9. **Prod**: freeze writes on the old platform, repeat the dump into the
|
|
79
|
+
prod database, merge `staging` to `main`, check `/api/ready` and the key
|
|
80
|
+
flows.
|
|
81
|
+
10. **Address**: `awesomate_app_domain_attach { appId, hostname:
|
|
82
|
+
'app.theirdomain.com' }`. The hostname must be a subdomain of a domain
|
|
83
|
+
already on the account (their website's domain usually is); the apex and
|
|
84
|
+
`www` stay with the website. It returns the CNAME to set and whether DNS
|
|
85
|
+
already points at us; call it again after they change DNS. Lower the TTL
|
|
86
|
+
before the cutover, then: freeze writes on the old platform, final data
|
|
87
|
+
import, switch the record, check `/api/ready` on the new hostname.
|
|
88
|
+
11. **Cleanup**: `awesomate_app_run_state` stopped for dev and staging;
|
|
89
|
+
remove Replit-only files; keep the old platform read-only for a week.
|
|
90
|
+
|
|
91
|
+
## Lovable
|
|
92
|
+
|
|
93
|
+
A Lovable app is a Vite React SPA on Supabase. Supabase stays where it is.
|
|
94
|
+
The app is `kind: 'static'` here; the build output is the site. The check
|
|
95
|
+
script recognises it and stops after the platform section; the static path
|
|
96
|
+
does not need the deploy key or the workflow. Full support is a later release.
|
|
97
|
+
|
|
98
|
+
## What you cannot do
|
|
99
|
+
|
|
100
|
+
- You cannot put the apex or `www` of their domain on the app; a subdomain
|
|
101
|
+
only (design decision D3). If they insist on the apex, that is a support
|
|
102
|
+
conversation, not a tool call.
|
|
103
|
+
- You cannot open a Desk ticket for platform problems; the hub does.
|
|
104
|
+
- You cannot lower an account limit or raise it above the platform floors;
|
|
105
|
+
`awesomate_app_capacity fix:true` is raise-only up to the floors.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Pre-deploy check for a repo that was NOT scaffolded by Awesomate (Replit,
|
|
4
|
+
* Lovable, anything). Zero dependencies. Prints a JSON report Claude posts
|
|
5
|
+
* to the hub with awesomate_app_repo_check, which stores it, updates the
|
|
6
|
+
* migration plan, and may open a support ticket for blockers.
|
|
7
|
+
*
|
|
8
|
+
* node migrate-check.mjs [<repo-dir>] [--fix]
|
|
9
|
+
*
|
|
10
|
+
* --fix applies the one repair that is purely mechanical: regenerating a
|
|
11
|
+
* lockfile that points at a private registry. Everything else is reported
|
|
12
|
+
* with the fix spelled out so Claude can do it in the code.
|
|
13
|
+
*
|
|
14
|
+
* Exit code is always 0. Severity: blocker (the deploy will fail or must not
|
|
15
|
+
* happen), warn (will bite later), info (worth knowing).
|
|
16
|
+
*/
|
|
17
|
+
import { execSync } from 'node:child_process';
|
|
18
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
19
|
+
import { join, relative, resolve } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const fix = args.includes('--fix');
|
|
23
|
+
const dir = resolve(args.find((a) => !a.startsWith('--')) ?? '.');
|
|
24
|
+
|
|
25
|
+
const findings = [];
|
|
26
|
+
let deploy = null;
|
|
27
|
+
const detected = { node: null, packageManager: 'npm', build: null, start: null, scripts: {}, deps: {}, devDeps: {}, ready_path: '/api/ready', hasReadyRoute: false, drizzle: false, drizzleSql: false, migrationsDir: null, usesPortEnv: false, usesDatabaseUrl: false, replit: {}, websockets: false, largeDirs: [], sourceFiles: 0 };
|
|
28
|
+
const add = (id, severity, title, extra = {}) => findings.push({ id, severity, title, ...extra });
|
|
29
|
+
|
|
30
|
+
const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
|
|
31
|
+
const pkg = readJson(join(dir, 'package.json'));
|
|
32
|
+
if (!pkg) {
|
|
33
|
+
add('no_package_json', 'blocker', 'No package.json at the repo root', { fix: 'Point me at the folder that holds package.json, or this is not a Node app.' });
|
|
34
|
+
finish('unknown');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
detected.scripts = pkg.scripts ?? {};
|
|
38
|
+
detected.deps = pkg.dependencies ?? {};
|
|
39
|
+
detected.devDeps = pkg.devDependencies ?? {};
|
|
40
|
+
detected.build = detected.scripts.build ?? null;
|
|
41
|
+
detected.start = detected.scripts.start ?? null;
|
|
42
|
+
detected.node = pkg.engines?.node ?? null;
|
|
43
|
+
|
|
44
|
+
// --- platform --------------------------------------------------------------
|
|
45
|
+
const hasReplitFiles = existsSync(join(dir, '.replit')) || existsSync(join(dir, 'replit.nix'));
|
|
46
|
+
const replitDeps = Object.keys({ ...detected.deps, ...detected.devDeps }).filter((d) => d.startsWith('@replit/'));
|
|
47
|
+
const lovable = existsSync(join(dir, 'supabase')) || Object.keys(detected.deps).some((d) => d.startsWith('@supabase/')) && existsSync(join(dir, 'vite.config.ts')) && !detected.deps.express;
|
|
48
|
+
const platform = hasReplitFiles || replitDeps.length ? 'replit' : lovable ? 'lovable' : existsSync(join(dir, '.awesomate.json')) ? 'scaffold' : 'unknown';
|
|
49
|
+
detected.replit = { files: hasReplitFiles, deps: replitDeps };
|
|
50
|
+
|
|
51
|
+
// --- source scan -------------------------------------------------------------
|
|
52
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'attached_assets', 'scripts']);
|
|
53
|
+
const SRC_RE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
54
|
+
const files = [];
|
|
55
|
+
(function walk(d, depth) {
|
|
56
|
+
if (depth > 8) return;
|
|
57
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
58
|
+
if (SKIP.has(e.name)) continue;
|
|
59
|
+
const p = join(d, e.name);
|
|
60
|
+
if (e.isDirectory()) walk(p, depth + 1);
|
|
61
|
+
else if (SRC_RE.test(e.name)) files.push(p);
|
|
62
|
+
}
|
|
63
|
+
})(dir, 0);
|
|
64
|
+
detected.sourceFiles = files.length;
|
|
65
|
+
|
|
66
|
+
const SECRET_RE = /(sk-[A-Za-z0-9]{16,}|sk_live_[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|xox[bp]-[A-Za-z0-9-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|postgres(ql)?:\/\/[^:\s]+:[^@\s]+@)/;
|
|
67
|
+
let readyFound = false, portEnv = false, portHardcoded = null, dbUrl = false, neon = false, replEnv = [], replitAuth = false, ws = false, evalUse = false;
|
|
68
|
+
for (const f of files) {
|
|
69
|
+
let text;
|
|
70
|
+
try { if (statSync(f).size > 1_000_000) continue; text = readFileSync(f, 'utf8'); } catch { continue; }
|
|
71
|
+
const rel = relative(dir, f);
|
|
72
|
+
if (SECRET_RE.test(text) && !/\.example\./.test(rel)) add('secret_in_source', 'blocker', 'Something shaped like a secret is committed', { file: rel, fix: 'Rotate it with the provider, remove it from the code, store it with awesomate_app_set_env.' });
|
|
73
|
+
if (/['"`]\/api\/ready['"`]/.test(text)) readyFound = true;
|
|
74
|
+
if (/process\.env\.PORT/.test(text)) portEnv = true;
|
|
75
|
+
const listen = text.match(/\.listen\(\s*(\d{4,5})\s*[,)]/);
|
|
76
|
+
if (listen && !/process\.env\.PORT/.test(text)) portHardcoded = { file: rel, port: listen[1] };
|
|
77
|
+
if (/process\.env\.DATABASE_URL/.test(text)) dbUrl = true;
|
|
78
|
+
if (/@neondatabase\/serverless|drizzle-orm\/neon-/.test(text)) neon = true;
|
|
79
|
+
for (const v of ['REPL_ID', 'REPLIT_DOMAINS', 'REPLIT_DEV_DOMAIN', 'REPL_SLUG', 'REPL_OWNER']) if (text.includes(v) && !replEnv.includes(v)) replEnv.push(v);
|
|
80
|
+
if (/replit\.com\/oidc|ISSUER_URL.*replit|openid-client/.test(text) && /REPL/.test(text)) replitAuth = true;
|
|
81
|
+
if (/from ['"](ws|socket\.io)['"]|require\(['"](ws|socket\.io)['"]\)|new WebSocketServer/.test(text)) ws = true;
|
|
82
|
+
if (/\beval\(|new Function\(/.test(text)) evalUse = true;
|
|
83
|
+
}
|
|
84
|
+
detected.hasReadyRoute = readyFound;
|
|
85
|
+
detected.usesPortEnv = portEnv;
|
|
86
|
+
detected.usesDatabaseUrl = dbUrl;
|
|
87
|
+
detected.websockets = ws;
|
|
88
|
+
|
|
89
|
+
// --- tracked .env ------------------------------------------------------------
|
|
90
|
+
try {
|
|
91
|
+
const tracked = execSync('git ls-files .env */.env', { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
92
|
+
if (tracked) add('tracked_env_file', 'blocker', 'A .env file is tracked by git', { file: tracked.split('\n')[0], fix: 'git rm --cached it, add .env to .gitignore, rotate anything in it.' });
|
|
93
|
+
} catch { /* not a git repo */ }
|
|
94
|
+
|
|
95
|
+
// --- lockfile ----------------------------------------------------------------
|
|
96
|
+
const lockPath = join(dir, 'package-lock.json');
|
|
97
|
+
if (!existsSync(lockPath)) {
|
|
98
|
+
const alt = ['yarn.lock', 'pnpm-lock.yaml', 'bun.lockb'].find((f) => existsSync(join(dir, f)));
|
|
99
|
+
if (alt) detected.packageManager = alt.split('.')[0].replace('lockb', 'bun');
|
|
100
|
+
add('no_npm_lockfile', 'blocker', alt ? `Uses ${alt}; the deploy runs npm ci` : 'No package-lock.json', { fix: 'Run npm install --package-lock-only to create package-lock.json (keep the other lockfile if you like).' });
|
|
101
|
+
} else {
|
|
102
|
+
const lock = readJson(lockPath);
|
|
103
|
+
const hosts = new Set();
|
|
104
|
+
const walkPkgs = (pkgs) => { for (const [, v] of Object.entries(pkgs ?? {})) { if (v?.resolved) { try { hosts.add(new URL(v.resolved).host); } catch { /* ignore */ } } } };
|
|
105
|
+
walkPkgs(lock?.packages); walkPkgs(lock?.dependencies);
|
|
106
|
+
const foreign = [...hosts].filter((h) => h !== 'registry.npmjs.org' && h !== 'github.com' && h !== 'codeload.github.com');
|
|
107
|
+
if (foreign.length) {
|
|
108
|
+
if (fix) {
|
|
109
|
+
try {
|
|
110
|
+
execSync('npm install --package-lock-only --ignore-scripts --registry https://registry.npmjs.org', { cwd: dir, stdio: 'pipe' });
|
|
111
|
+
add('lockfile_private_registry', 'info', `Lockfile pointed at ${foreign.join(', ')}; regenerated against the public registry`, { fix: 'Commit the new package-lock.json.' });
|
|
112
|
+
} catch (err) {
|
|
113
|
+
add('lockfile_private_registry', 'blocker', `Lockfile resolves packages from ${foreign.join(', ')} and regeneration failed`, { detail: String(err.message).slice(0, 300), fix: 'rm package-lock.json && npm install --package-lock-only --registry https://registry.npmjs.org' });
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
add('lockfile_private_registry', 'blocker', `Lockfile resolves packages from ${foreign.join(', ')}, unreachable from Awesomate and from GitHub Actions`, { fix: 'node migrate-check.mjs --fix (regenerates the lockfile against registry.npmjs.org), then commit it.' });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- app contract --------------------------------------------------------------
|
|
122
|
+
if (!detected.build) add('no_build_script', 'blocker', 'package.json has no "build" script', { fix: 'Add one that produces the artefacts (for example vite build && esbuild server/index.ts --bundle --platform=node --format=esm --outdir=dist).' });
|
|
123
|
+
if (!readyFound) add('ready_route_missing', 'blocker', 'No /api/ready route', { fix: 'Add a route that returns 200 only when the database answers (see AWESOMATE.md). The deploy and health checks probe it.' });
|
|
124
|
+
if (portHardcoded) add('port_hardcoded', 'blocker', `Server listens on a fixed port ${portHardcoded.port}`, { file: portHardcoded.file, fix: 'Use Number(process.env.PORT); the account assigns one port per environment.' });
|
|
125
|
+
else if (!portEnv) add('port_env_unknown', 'warn', 'Could not confirm the server reads process.env.PORT', { fix: 'Make sure app.listen uses process.env.PORT.' });
|
|
126
|
+
if (!dbUrl && (detected.deps.pg || detected.deps['drizzle-orm'] || detected.deps['@neondatabase/serverless'])) add('database_url_unused', 'warn', 'A database driver is present but DATABASE_URL is not read', { fix: 'Connect with process.env.DATABASE_URL; provisioning writes it per environment.' });
|
|
127
|
+
if (neon || detected.deps['@neondatabase/serverless']) add('neon_serverless_driver', 'blocker', 'Uses the Neon serverless driver, which only speaks to Neon over HTTP/WebSocket', { fix: "Switch to 'pg' with drizzle-orm/node-postgres (npm i pg && npm rm @neondatabase/serverless ws)." });
|
|
128
|
+
|
|
129
|
+
const drizzleCfg = ['drizzle.config.ts', 'drizzle.config.js', 'drizzle.config.mjs'].find((f) => existsSync(join(dir, f)));
|
|
130
|
+
if (drizzleCfg) {
|
|
131
|
+
detected.drizzle = true;
|
|
132
|
+
detected.drizzleSql = existsSync(join(dir, 'drizzle', 'meta', '_journal.json'));
|
|
133
|
+
if (!detected.drizzleSql) add('drizzle_no_generated_sql', 'warn', 'Drizzle is configured but no generated SQL is committed; drizzle-kit push cannot reach the account database', { fix: 'Add "drizzle-kit generate" to the build script, commit drizzle/, add "drizzle" to deploy.artifacts and set deploy.migrate to "node --env-file=.env scripts/awesomate-migrate.mjs".' });
|
|
134
|
+
} else if (existsSync(join(dir, 'migrations'))) {
|
|
135
|
+
detected.migrationsDir = 'migrations';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (replitDeps.length) add('replit_dev_deps', 'warn', `Replit-only packages present: ${replitDeps.join(', ')}`, { fix: 'Remove them from package.json and vite.config.ts; they do nothing outside Replit.' });
|
|
139
|
+
if (hasReplitFiles) add('replit_config_files', 'info', '.replit / replit.nix present', { fix: 'Harmless; delete when the move is done.' });
|
|
140
|
+
if (replEnv.length) add('replit_env_reads', 'warn', `Code reads Replit-only variables: ${replEnv.join(', ')}`, { fix: 'Replace with your own configuration or remove; they are unset on Awesomate.' });
|
|
141
|
+
if (replitAuth) add('replit_auth', 'warn', 'Replit Auth (OIDC against replit.com) is used for sign-in', { fix: 'It stops working off Replit. Replace with your own auth (session + password, or another OIDC provider).' });
|
|
142
|
+
if (ws) add('websockets_proxy', 'info', 'WebSockets are used', { fix: 'Nothing to do: the reverse proxy forwards the Upgrade handshake on every environment address and attached hostname.' });
|
|
143
|
+
if (evalUse) add('dynamic_code_eval', 'warn', 'eval() or new Function() in source', { fix: 'The prod security gate blocks these; replace before merging to main.' });
|
|
144
|
+
if (detected.node && !/(^|[^0-9])22([^0-9]|$)|>=\s*(1[8-9]|2[0-2])/.test(String(detected.node))) add('node_engine_mismatch', 'warn', `engines.node is "${detected.node}"; Awesomate runs Node 22`, { fix: 'Test on Node 22 locally; update engines.' });
|
|
145
|
+
if (pkg.scripts?.start && /NODE_ENV=/.test(pkg.scripts.start)) add('start_sets_node_env', 'info', 'start script sets NODE_ENV inline', { fix: 'Not needed: the account .env sets NODE_ENV per environment. deploy.start is the entry file, e.g. dist/index.js.' });
|
|
146
|
+
if (existsSync(join(dir, 'main.py'))) add('python_file_present', 'info', 'main.py present', { fix: 'Only Node runs on the account; delete it if it is a leftover.' });
|
|
147
|
+
|
|
148
|
+
for (const name of readdirSync(dir, { withFileTypes: true })) {
|
|
149
|
+
if (!name.isDirectory() || SKIP.has(name.name) && name.name !== 'attached_assets') continue;
|
|
150
|
+
let size = 0, count = 0;
|
|
151
|
+
(function du(p, depth) { if (depth > 6) return; for (const e of readdirSync(p, { withFileTypes: true })) { const q = join(p, e.name); if (e.isDirectory()) du(q, depth + 1); else { count += 1; try { size += statSync(q).size; } catch { /* ignore */ } } } })(join(dir, name.name), 0);
|
|
152
|
+
if (size > 50 * 1024 * 1024) { detected.largeDirs.push({ dir: name.name, mb: Math.round(size / 1048576), files: count }); add('large_assets_dir', 'warn', `${name.name}/ is ${Math.round(size / 1048576)} MB (${count} files)`, { fix: 'Do not ship it through the deploy; serve static assets from public_html or object storage and keep the repo small.' }); }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- suggested deploy contract ---------------------------------------------------
|
|
156
|
+
const artifacts = ['dist', 'package.json', 'package-lock.json'];
|
|
157
|
+
if (detected.drizzle) artifacts.splice(1, 0, 'drizzle');
|
|
158
|
+
else if (detected.migrationsDir) artifacts.splice(1, 0, 'migrations');
|
|
159
|
+
deploy = {
|
|
160
|
+
node: 22,
|
|
161
|
+
build: detected.drizzle && !/drizzle-kit generate/.test(detected.build ?? '') ? 'npx drizzle-kit generate && npm run build' : 'npm run build',
|
|
162
|
+
artifacts,
|
|
163
|
+
install: 'npm ci --omit=dev',
|
|
164
|
+
migrate: detected.drizzle ? 'node --env-file=.env scripts/awesomate-migrate.mjs' : detected.migrationsDir ? 'node --env-file=.env scripts/awesomate-migrate.mjs migrations' : null,
|
|
165
|
+
start: 'dist/index.js',
|
|
166
|
+
ready_path: '/api/ready',
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
finish(platform);
|
|
170
|
+
|
|
171
|
+
function finish(plat) {
|
|
172
|
+
const blockers = findings.filter((f) => f.severity === 'blocker').length;
|
|
173
|
+
const report = { version: 1, platform: plat, findings, detected, deploy, summary: { blockers, warnings: findings.filter((f) => f.severity === 'warn').length, fixApplied: fix } };
|
|
174
|
+
console.log(JSON.stringify(report, null, 2));
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
|
@@ -59,29 +59,44 @@ git checkout -b dev
|
|
|
59
59
|
git push -u origin dev
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
## 2. Push-to-deploy (
|
|
63
|
-
|
|
64
|
-
Node apps
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
62
|
+
## 2. Push-to-deploy (Node apps on Awesomate)
|
|
63
|
+
|
|
64
|
+
Node apps deploy on a push to `dev` / `staging` / `main` (dev → dev, main →
|
|
65
|
+
prod). GitHub Actions builds on its own runner and ships only the built
|
|
66
|
+
files to the account; **nothing builds on the hosting account**, and neither
|
|
67
|
+
should you over SSH. Once wired, explain it as *"save your work = it goes
|
|
68
|
+
live"*; don't lecture on branches.
|
|
69
|
+
|
|
70
|
+
**Wiring it is one command, run from the repo folder, and needs no ticket:**
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
node ~/.claude/skills/awesomate-github/scripts/deploy-key.mjs <appId>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
It generates a key **for this app only**, authorises it on the user's own
|
|
77
|
+
hosting account through the hub (Support Plus and above), sets
|
|
78
|
+
`SSH_PRIVATE_KEY`, `SSH_HOST` and `SSH_USER` on the repo with `gh secret set`,
|
|
79
|
+
shreds the private key locally, and tells the hub which repo the app deploys
|
|
80
|
+
from. Re-running it is safe; `--rotate` replaces the key. `<appId>` comes from
|
|
81
|
+
`awesomate_app_list` / `awesomate_app_get`.
|
|
82
|
+
|
|
83
|
+
The repo also needs the Awesomate plumbing: a scaffolded app already has it;
|
|
84
|
+
an existing repo (Replit, Lovable, anything) gets it from
|
|
85
|
+
`awesomate_app_scaffold` with `mode: 'adopt'` after the repo check. The full
|
|
86
|
+
order for an existing repo lives in the app-builder skill's
|
|
87
|
+
`references/migrate-from-replit.md`, and `awesomate_app_migration_plan` tells
|
|
88
|
+
you which step is next.
|
|
89
|
+
|
|
90
|
+
**When a deploy run goes red**, read the failing step (`gh run view
|
|
91
|
+
--log-failed`), report it with `awesomate_app_deploy_event`, fix the cause,
|
|
92
|
+
and push again. Three failures on one environment inside a day make the hub
|
|
93
|
+
open a support ticket itself and show it in the plan; tell the user
|
|
94
|
+
"Awesomate has been notified" and keep working. Do not raise that ticket
|
|
95
|
+
yourself and do not loop on pushes.
|
|
96
|
+
|
|
97
|
+
Static sites have no deploy workflow at all: the template is just the page
|
|
98
|
+
files, so pushing does not publish them. They are copied to the site's
|
|
99
|
+
document root over the account SSH (awesomate-hosting `ssh-connect.sh`).
|
|
85
100
|
|
|
86
101
|
## 3. Everyday use (plain language)
|
|
87
102
|
|
|
@@ -107,13 +122,13 @@ control — they should never have to think about it:
|
|
|
107
122
|
aren't backed up anywhere — want me to set that up? Takes a minute."*
|
|
108
123
|
`env_file_tracked` is urgent: untrack it, .gitignore it, and route the
|
|
109
124
|
secrets through awesomate-credentials.
|
|
110
|
-
**`last_deploy_failed
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
125
|
+
**`last_deploy_failed`:** first check the three deploy secrets exist
|
|
126
|
+
(`gh secret list`). If they are missing, run `deploy-key.mjs <appId>` (§2);
|
|
127
|
+
that is the fix, not a ticket. If they exist, read the failing step with
|
|
128
|
+
`gh run view --log-failed`, report it with `awesomate_app_deploy_event`,
|
|
129
|
+
fix the cause and push once. A "Killed" or exit 137 inside a step that runs
|
|
130
|
+
on the account means memory: the build must move to the runner (it does by
|
|
131
|
+
default; check `.awesomate.json` deploy.build is not being run over SSH).
|
|
117
132
|
- **Auto-checkpoint.** After every meaningful working change (feature works,
|
|
118
133
|
bug fixed, content updated), commit with a plain-English message — don't
|
|
119
134
|
wait for "save my work", and never ask them to write a message. Push at
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Wire push-to-deploy for an Awesomate app from the client's own machine.
|
|
4
|
+
*
|
|
5
|
+
* node deploy-key.mjs <appId> [--path <repo-dir>] [--rotate]
|
|
6
|
+
*
|
|
7
|
+
* 1. Generates a fresh ed25519 keypair for THIS app (never the machine key).
|
|
8
|
+
* 2. Registers the public half on the hosting account through the hub
|
|
9
|
+
* (POST /api/hosting-access/ssh-keys, Support Plus and above).
|
|
10
|
+
* 3. Sets SSH_PRIVATE_KEY, SSH_HOST and SSH_USER as GitHub Actions secrets on
|
|
11
|
+
* the repo in <repo-dir> (gh CLI, already authenticated).
|
|
12
|
+
* 4. Shreds the private key locally and tells the hub which repo and key
|
|
13
|
+
* fingerprint the app now uses (PATCH /api/my-apps/apps/<id>).
|
|
14
|
+
*
|
|
15
|
+
* Idempotent: if the repo already has the three secrets and --rotate is not
|
|
16
|
+
* given, it only refreshes the hub's record. --rotate mints a new key AND
|
|
17
|
+
* revokes the previous one from the hosting account, so a leaked key stops
|
|
18
|
+
* working. Prints names and fingerprints, never key material.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { tmpdir, homedir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const flags = new Set(args.filter((a) => a.startsWith('--')));
|
|
27
|
+
const positional = args.filter((a) => !a.startsWith('--'));
|
|
28
|
+
const appId = Number(positional[0]);
|
|
29
|
+
const pathIdx = args.indexOf('--path');
|
|
30
|
+
const cwd = pathIdx >= 0 && args[pathIdx + 1] ? args[pathIdx + 1] : process.cwd();
|
|
31
|
+
const rotate = flags.has('--rotate');
|
|
32
|
+
|
|
33
|
+
if (!Number.isInteger(appId) || appId <= 0) {
|
|
34
|
+
console.error('usage: node deploy-key.mjs <appId> [--path <repo-dir>] [--rotate]');
|
|
35
|
+
console.error('appId: the number from awesomate_app_list / awesomate_app_get');
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function run(cmd, cmdArgs, opts = {}) {
|
|
40
|
+
return spawnSync(cmd, cmdArgs, { cwd, encoding: 'utf8', ...opts });
|
|
41
|
+
}
|
|
42
|
+
function die(msg) {
|
|
43
|
+
console.error(`\n✗ ${msg}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- account: API base, token and SSH block from the hosting skill's resolver --
|
|
48
|
+
const resolver = join(homedir(), '.claude', 'skills', 'awesomate-hosting', 'scripts', 'resolve-account.mjs');
|
|
49
|
+
if (!existsSync(resolver)) {
|
|
50
|
+
die('The awesomate-hosting skill is not installed on this machine. Connect Claude Code from hub.awesomate.ai/sites first.');
|
|
51
|
+
}
|
|
52
|
+
const resolved = spawnSync('node', [resolver, '--api', '--ssh'], { encoding: 'utf8' });
|
|
53
|
+
if (resolved.status !== 0) {
|
|
54
|
+
die((resolved.stderr || resolved.stdout || '').trim() || 'Could not resolve the connected Awesomate account.');
|
|
55
|
+
}
|
|
56
|
+
const kv = {};
|
|
57
|
+
for (const part of resolved.stdout.trim().split(';')) {
|
|
58
|
+
const m = part.trim().match(/^([A-Z]+)='(.*)'$/);
|
|
59
|
+
if (m) kv[m[1]] = m[2];
|
|
60
|
+
}
|
|
61
|
+
const { ACCT, API, PAT, H: sshHost, U: sshUser } = kv;
|
|
62
|
+
if (!API || !PAT) die('No access token found for this account. Reconnect from hub.awesomate.ai/sites.');
|
|
63
|
+
if (!sshHost || !sshUser) die('This plan has no shell access (Support Plus and above), so a deploy key cannot be authorised.');
|
|
64
|
+
console.log(`→ account: ${ACCT} hosting user: ${sshUser}@${sshHost}`);
|
|
65
|
+
|
|
66
|
+
async function hub(method, path, body) {
|
|
67
|
+
const res = await fetch(`${API}${path}`, {
|
|
68
|
+
method,
|
|
69
|
+
headers: { Authorization: `Bearer ${PAT}`, 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
70
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
71
|
+
});
|
|
72
|
+
const text = await res.text();
|
|
73
|
+
let json = {};
|
|
74
|
+
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text.slice(0, 200) }; }
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
const msg = json.message || json.error || `${res.status}`;
|
|
77
|
+
throw new Error(`${method} ${path} → ${res.status}: ${msg}`);
|
|
78
|
+
}
|
|
79
|
+
return json;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- repo ------------------------------------------------------------------
|
|
83
|
+
if (run('gh', ['--version']).status !== 0) die("GitHub CLI 'gh' is not installed (macOS: brew install gh).");
|
|
84
|
+
if (run('gh', ['auth', 'status']).status !== 0) die('Not connected to GitHub. Run: gh auth login');
|
|
85
|
+
const repoView = run('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']);
|
|
86
|
+
if (repoView.status !== 0) die(`No GitHub repo in ${cwd}. Run github-setup.mjs first.`);
|
|
87
|
+
const repo = repoView.stdout.trim();
|
|
88
|
+
console.log(`→ repo: ${repo}`);
|
|
89
|
+
|
|
90
|
+
const existing = run('gh', ['secret', 'list', '--json', 'name', '-q', '.[].name']).stdout.split(/\r?\n/).filter(Boolean);
|
|
91
|
+
const haveAll = ['SSH_PRIVATE_KEY', 'SSH_HOST', 'SSH_USER'].every((n) => existing.includes(n));
|
|
92
|
+
|
|
93
|
+
let previousFingerprint = null;
|
|
94
|
+
try {
|
|
95
|
+
const current = await hub('GET', `/api/my-apps/apps/${appId}`);
|
|
96
|
+
previousFingerprint = current?.app?.deploy_key_fingerprint ?? null;
|
|
97
|
+
} catch { /* the hub record is optional here */ }
|
|
98
|
+
|
|
99
|
+
let fingerprint = null;
|
|
100
|
+
if (haveAll && !rotate) {
|
|
101
|
+
console.log('• deploy secrets already present on the repo (pass --rotate to replace the key)');
|
|
102
|
+
} else {
|
|
103
|
+
// --- key ----------------------------------------------------------------
|
|
104
|
+
const dir = mkdtempSync(join(tmpdir(), 'awm-deploy-key-'));
|
|
105
|
+
const keyPath = join(dir, 'key');
|
|
106
|
+
try {
|
|
107
|
+
const gen = spawnSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-C', `awesomate-deploy:${ACCT}:app${appId}`, '-f', keyPath], { encoding: 'utf8' });
|
|
108
|
+
if (gen.status !== 0) die(`ssh-keygen failed: ${(gen.stderr || '').trim()}`);
|
|
109
|
+
const publicKey = readFileSync(`${keyPath}.pub`, 'utf8').trim();
|
|
110
|
+
|
|
111
|
+
const reg = await hub('POST', '/api/hosting-access/ssh-keys', {
|
|
112
|
+
publicKey,
|
|
113
|
+
label: `deploy:app${appId} (${repo})`,
|
|
114
|
+
});
|
|
115
|
+
fingerprint = reg.fingerprint ?? null;
|
|
116
|
+
const host = reg.sshHost || sshHost;
|
|
117
|
+
const user = reg.sshUser || sshUser;
|
|
118
|
+
console.log(`• deploy key authorised on ${user}@${host}${fingerprint ? ` (${fingerprint})` : ''}`);
|
|
119
|
+
|
|
120
|
+
const priv = readFileSync(keyPath, 'utf8');
|
|
121
|
+
for (const [name, value] of [['SSH_PRIVATE_KEY', priv], ['SSH_HOST', host], ['SSH_USER', user]]) {
|
|
122
|
+
const set = run('gh', ['secret', 'set', name], { input: value });
|
|
123
|
+
if (set.status !== 0) die(`gh secret set ${name} failed: ${(set.stderr || '').trim()}`);
|
|
124
|
+
console.log(`• set repo secret ${name}`);
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
// Overwrite before unlinking so the private half does not linger in tmp.
|
|
128
|
+
try { if (existsSync(keyPath)) writeFileSync(keyPath, '\0'.repeat(4096)); } catch { /* best effort */ }
|
|
129
|
+
rmSync(dir, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- revoke the previous key on rotate --------------------------------------
|
|
134
|
+
if (rotate && fingerprint && previousFingerprint && previousFingerprint !== fingerprint) {
|
|
135
|
+
try {
|
|
136
|
+
const list = await hub('GET', '/api/hosting-access/ssh-keys');
|
|
137
|
+
const old = (list?.keys ?? []).find((k) => k.fingerprint === previousFingerprint);
|
|
138
|
+
if (old) {
|
|
139
|
+
await hub('DELETE', `/api/hosting-access/ssh-keys/${old.id}`);
|
|
140
|
+
console.log(`• revoked the previous deploy key (${previousFingerprint})`);
|
|
141
|
+
} else {
|
|
142
|
+
console.log(`• previous key ${previousFingerprint} is not in the hub registry; revoke it from hub.awesomate.ai/settings?tab=access if it still exists`);
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.log(`• could not revoke the previous key (${err.message}); revoke it from hub.awesomate.ai/settings?tab=access`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- tell the hub -----------------------------------------------------------
|
|
150
|
+
const patch = { githubRepo: repo };
|
|
151
|
+
if (fingerprint) patch.deployKeyFingerprint = fingerprint;
|
|
152
|
+
try {
|
|
153
|
+
await hub('PATCH', `/api/my-apps/apps/${appId}`, patch);
|
|
154
|
+
console.log(`• hub updated: app ${appId} deploys from ${repo}`);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.log(`• could not update the hub record (${err.message}); the secrets are set, so deploys will work. Ask Claude to run awesomate_app_update.`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log('\n✓ Push-to-deploy is wired. Push the dev branch to deploy dev; gh run watch to follow.');
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Zero-knowledge GitHub scaffolder. Idempotent — safe to re-run.
|
|
4
4
|
*
|
|
5
|
-
* node github-setup.mjs <repo-name> [--public] [--path <dir>]
|
|
5
|
+
* node github-setup.mjs <repo-name> [--public] [--path <dir>]
|
|
6
6
|
*
|
|
7
7
|
* Steps: verify gh auth → ensure .gitignore excludes .env → SECRET GUARD
|
|
8
8
|
* (refuse to commit a .env or an obvious key/token) → git init + first commit →
|
|
9
|
-
* `gh repo create --source=. --push` (private by default; personal accounts OK)
|
|
10
|
-
* → optionally seed deploy Actions secrets from the environment.
|
|
9
|
+
* `gh repo create --source=. --push` (private by default; personal accounts OK).
|
|
11
10
|
*
|
|
12
|
-
* Deploy secrets are
|
|
13
|
-
*
|
|
11
|
+
* Deploy secrets are NOT handled here: run deploy-key.mjs <appId> afterwards,
|
|
12
|
+
* which mints a per-app key and sets SSH_HOST/SSH_USER/SSH_PRIVATE_KEY itself.
|
|
13
|
+
* Prints only non-sensitive output.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { spawnSync } from 'node:child_process';
|
|
@@ -24,10 +24,9 @@ const repoName = positional[0];
|
|
|
24
24
|
const pathIdx = args.indexOf('--path');
|
|
25
25
|
const cwd = pathIdx >= 0 && args[pathIdx + 1] ? args[pathIdx + 1] : process.cwd();
|
|
26
26
|
const isPublic = flags.has('--public');
|
|
27
|
-
const wantDeploySecrets = flags.has('--deploy-secrets');
|
|
28
27
|
|
|
29
28
|
if (!repoName || !/^[a-z0-9][a-z0-9._-]{0,99}$/i.test(repoName)) {
|
|
30
|
-
console.error('usage: node github-setup.mjs <repo-name> [--public] [--path <dir>]
|
|
29
|
+
console.error('usage: node github-setup.mjs <repo-name> [--public] [--path <dir>]');
|
|
31
30
|
console.error('repo-name: letters/digits/._- , starts alphanumeric');
|
|
32
31
|
process.exit(2);
|
|
33
32
|
}
|
|
@@ -111,19 +110,6 @@ if (already.status === 0) {
|
|
|
111
110
|
console.log(`• created ${isPublic ? 'public' : 'private'} repo and pushed`);
|
|
112
111
|
}
|
|
113
112
|
|
|
114
|
-
// --- 5. deploy secrets (values from env, never argv) ----------------------
|
|
115
|
-
if (wantDeploySecrets) {
|
|
116
|
-
for (const name of ['SSH_HOST', 'SSH_USER', 'SSH_PRIVATE_KEY']) {
|
|
117
|
-
const val = process.env[name];
|
|
118
|
-
if (!val) {
|
|
119
|
-
console.log(`• skip deploy secret ${name} (not in environment)`);
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
const set = run('gh', ['secret', 'set', name], { input: val });
|
|
123
|
-
console.log(set.status === 0 ? `• set deploy secret ${name}` : `• FAILED to set ${name}: ${(set.stderr || '').trim()}`);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
113
|
// --- done ------------------------------------------------------------------
|
|
128
114
|
const url = run('gh', ['repo', 'view', '--json', 'url', '-q', '.url']).stdout.trim();
|
|
129
115
|
console.log(`\n✓ Done. Your project is on GitHub${url ? `: ${url}` : ''}.`);
|
|
@@ -362,6 +362,11 @@ skill drive the Git workflow; this skill just deploys the result.
|
|
|
362
362
|
setup (Support Plus+). Invocation + failure modes:
|
|
363
363
|
`references/connect-troubleshooting.md`.
|
|
364
364
|
- `ssh-connect.sh` — open a jailed SSH session (or run one remote command).
|
|
365
|
+
- `deploy-artifacts.sh <dev|staging|prod>` — manual deploy of a Node app when
|
|
366
|
+
GitHub Actions is not available: builds on THIS machine from the repo's
|
|
367
|
+
`.awesomate.json` contract, ships only the artefacts over the jailed SSH,
|
|
368
|
+
installs, migrates, restarts, probes readiness. It refuses to run on the
|
|
369
|
+
hosting account itself: nothing builds there, ever.
|
|
365
370
|
- `wp.sh` — run a WP-CLI command against a live site over that SSH.
|
|
366
371
|
- `deploy.sh` — snapshot-first Studio→live deploy (files, optional DB).
|
|
367
372
|
- `pull-live.sh` — clone a live site down to a local folder (read-only on live).
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Manual deploy of an Awesomate Node app WITHOUT GitHub Actions: build here,
|
|
3
|
+
# ship only the artefacts, install and restart on the account. Reads the same
|
|
4
|
+
# .awesomate.json "deploy" contract the workflow uses.
|
|
5
|
+
#
|
|
6
|
+
# deploy-artifacts.sh <dev|staging|prod> [--path <repo-dir>] [--skip-build]
|
|
7
|
+
#
|
|
8
|
+
# It refuses to run on the hosting account itself: the account shares one
|
|
9
|
+
# memory ceiling across every environment and the website, and a build there
|
|
10
|
+
# is how deploys die with "Killed".
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
ENV_NAME="${1:-}"; shift || true
|
|
14
|
+
DIR="$(pwd)"; SKIP_BUILD=0
|
|
15
|
+
while [ $# -gt 0 ]; do
|
|
16
|
+
case "$1" in
|
|
17
|
+
--path) DIR="$2"; shift 2 ;;
|
|
18
|
+
--skip-build) SKIP_BUILD=1; shift ;;
|
|
19
|
+
*) echo "unknown option $1" >&2; exit 2 ;;
|
|
20
|
+
esac
|
|
21
|
+
done
|
|
22
|
+
case "$ENV_NAME" in dev|staging|prod) ;; *) echo "usage: deploy-artifacts.sh <dev|staging|prod> [--path <repo-dir>] [--skip-build]" >&2; exit 2 ;; esac
|
|
23
|
+
cd "$DIR"
|
|
24
|
+
[ -f .awesomate.json ] || { echo "✗ .awesomate.json not found in $DIR (awesomate_app_scaffold mode adopt writes it)" >&2; exit 1; }
|
|
25
|
+
command -v jq >/dev/null || { echo "✗ jq is required (macOS: brew install jq)" >&2; exit 1; }
|
|
26
|
+
|
|
27
|
+
if [[ "$(hostname -f 2>/dev/null || hostname)" == *awesomate.io* ]] || [[ "$HOME" == /home/* && -d /usr/local/cpanel ]]; then
|
|
28
|
+
echo "✗ This looks like the hosting account. Never build on the account: run this from your own machine." >&2
|
|
29
|
+
exit 1
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
33
|
+
if ! CREDS_EVAL="$(node "$SCRIPT_DIR/resolve-account.mjs" --ssh)"; then exit 1; fi
|
|
34
|
+
eval "$CREDS_EVAL"
|
|
35
|
+
echo "→ account: $ACCT ($U@$H)"
|
|
36
|
+
|
|
37
|
+
APP_SLUG="$(jq -r '.app_slug // empty' .awesomate.json)"; [ -n "$APP_SLUG" ] || { echo "✗ .awesomate.json has no app_slug" >&2; exit 1; }
|
|
38
|
+
ENV_URL="$(jq -r --arg e "$ENV_NAME" '.environments[$e].url // empty' .awesomate.json)"
|
|
39
|
+
BUILD_CMD="$(jq -r '.deploy.build // "npm run build"' .awesomate.json)"
|
|
40
|
+
INSTALL_CMD="$(jq -r '.deploy.install // "npm ci --omit=dev"' .awesomate.json)"
|
|
41
|
+
MIGRATE_CMD="$(jq -r '.deploy.migrate // empty' .awesomate.json)"
|
|
42
|
+
START_SCRIPT="$(jq -r '.deploy.start // "dist/index.js"' .awesomate.json)"
|
|
43
|
+
READY_PATH="$(jq -r '.deploy.ready_path // "/api/ready"' .awesomate.json)"
|
|
44
|
+
ARTIFACTS="$(jq -r '(.deploy.artifacts // ["dist","package.json","package-lock.json"]) | join(" ")' .awesomate.json)"
|
|
45
|
+
REMOTE_DIR="/home/$U/apps/${APP_SLUG}-${ENV_NAME}"
|
|
46
|
+
NAME="${APP_SLUG}-${ENV_NAME}"
|
|
47
|
+
SSH=(ssh -i "$K" -p "$P" -o StrictHostKeyChecking=accept-new "$U@$H")
|
|
48
|
+
|
|
49
|
+
if [ "$ENV_NAME" = "prod" ]; then
|
|
50
|
+
read -r -p "This deploys to PRODUCTION ($ENV_URL). Type the app slug to continue: " confirm
|
|
51
|
+
[ "$confirm" = "$APP_SLUG" ] || { echo "aborted"; exit 1; }
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
if [ "$SKIP_BUILD" = 0 ]; then
|
|
55
|
+
echo "→ building locally: $BUILD_CMD"
|
|
56
|
+
npm ci
|
|
57
|
+
bash -eo pipefail -c "$BUILD_CMD"
|
|
58
|
+
case " $ARTIFACTS " in *" node_modules "*) npm prune --omit=dev ;; esac
|
|
59
|
+
fi
|
|
60
|
+
for a in $ARTIFACTS; do [ -e "$a" ] || { echo "✗ artefact '$a' does not exist after the build" >&2; exit 1; }; done
|
|
61
|
+
|
|
62
|
+
echo "→ shipping $ARTIFACTS to $REMOTE_DIR"
|
|
63
|
+
"${SSH[@]}" "mkdir -p '$REMOTE_DIR'"
|
|
64
|
+
# shellcheck disable=SC2086
|
|
65
|
+
tar czf - $ARTIFACTS | "${SSH[@]}" bash -lc "set -eo pipefail; cd '$REMOTE_DIR' && tar xzf - && export PATH=\"\$HOME/.npm-global/bin:/opt/cpanel/ea-nodejs22/bin:\$PATH\" && if [ -n '$INSTALL_CMD' ]; then $INSTALL_CMD; fi"
|
|
66
|
+
if [ -n "$MIGRATE_CMD" ]; then
|
|
67
|
+
echo "→ migrating: $MIGRATE_CMD"
|
|
68
|
+
"${SSH[@]}" bash -lc "set -eo pipefail; cd '$REMOTE_DIR' && export PATH=\"\$HOME/.npm-global/bin:/opt/cpanel/ea-nodejs22/bin:\$PATH\" && $MIGRATE_CMD"
|
|
69
|
+
fi
|
|
70
|
+
echo "→ restarting $NAME"
|
|
71
|
+
"${SSH[@]}" bash -lc "cd '$REMOTE_DIR' && (~/.npm-global/bin/pm2 restart $NAME --update-env || ~/.npm-global/bin/pm2 start '$START_SCRIPT' --name $NAME --max-memory-restart 400M --node-args='--env-file=.env') && ~/.npm-global/bin/pm2 save"
|
|
72
|
+
|
|
73
|
+
echo "→ readiness: ${ENV_URL}${READY_PATH}"
|
|
74
|
+
for i in 1 2 3 4 5 6; do
|
|
75
|
+
sleep 5
|
|
76
|
+
code="$(curl -s -o /dev/null -w '%{http_code}' "${ENV_URL}${READY_PATH}" || true)"
|
|
77
|
+
if [ "$code" = "200" ]; then echo "✓ $ENV_NAME is ready at $ENV_URL"; exit 0; fi
|
|
78
|
+
echo " attempt $i: HTTP $code"
|
|
79
|
+
done
|
|
80
|
+
echo "✗ readiness failed. 503: process not listening or database unreachable; 404: no ${READY_PATH} route." >&2
|
|
81
|
+
exit 1
|