@awesomate/hosting-mcp 0.26.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 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,7 +40974,14 @@ 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",
40984
+ awesomate_app_run_state: "Start or stop an app environment",
40972
40985
  awesomate_app_set_env: "Set app secret",
40973
40986
  awesomate_app_provision_db: "Add database to app",
40974
40987
  awesomate_n8n_workflows: "List n8n workflows",
@@ -41686,7 +41699,7 @@ server.registerTool(
41686
41699
  "awesomate_app_get",
41687
41700
  {
41688
41701
  annotations: annotate("awesomate_app_get", READ_ONLY),
41689
- 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).",
41690
41703
  inputSchema: { appId: external_exports.number().int().positive().describe("The app id from awesomate_app_list / _create") }
41691
41704
  },
41692
41705
  async ({ appId }) => {
@@ -41723,12 +41736,15 @@ server.registerTool(
41723
41736
  "awesomate_app_scaffold",
41724
41737
  {
41725
41738
  annotations: annotate("awesomate_app_scaffold", READ_ONLY),
41726
- 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.",
41727
- inputSchema: { appId: external_exports.number().int().positive().describe("The app id from awesomate_app_create / _list") }
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
+ }
41728
41744
  },
41729
- async ({ appId }) => {
41745
+ async ({ appId, mode }) => {
41730
41746
  try {
41731
- 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" : ""}`));
41732
41748
  } catch (err) {
41733
41749
  return errorResult(err);
41734
41750
  }
@@ -41738,7 +41754,7 @@ server.registerTool(
41738
41754
  "awesomate_app_deploy_info",
41739
41755
  {
41740
41756
  annotations: annotate("awesomate_app_deploy_info", READ_ONLY),
41741
- 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). Use the awesomate-github skill to wire push-to-deploy the first time.",
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.",
41742
41758
  inputSchema: { appId: external_exports.number().int().positive().describe("The app id") }
41743
41759
  },
41744
41760
  async ({ appId }) => {
@@ -41749,6 +41765,130 @@ server.registerTool(
41749
41765
  }
41750
41766
  }
41751
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
+ );
41752
41892
  server.registerTool(
41753
41893
  "awesomate_app_set_env",
41754
41894
  {
@@ -41803,6 +41943,27 @@ server.registerTool(
41803
41943
  }
41804
41944
  }
41805
41945
  );
41946
+ server.registerTool(
41947
+ "awesomate_app_run_state",
41948
+ {
41949
+ annotations: annotate("awesomate_app_run_state", MUTATING),
41950
+ description: "Start or stop ONE environment of a Node app (its pm2 process). Use `stopped` to turn off a dev or staging environment the user has finished with. This frees process capacity on their hosting account, which is a real constraint: the account has a fixed number of processes it can run at once, and idle environments can starve the account's own websites. Use `running` to bring one back before you build or test on it. Stopping deletes NOTHING: code, database and address all stay, and starting again takes seconds. PRODUCTION CANNOT BE STOPPED (400 `prod_not_stoppable`); it is the address the user's customers reach. A 409 `run_state_failed` on a start means the account has no spare capacity, and the response carries nprocUsed/nprocLimit; tell the user which environment to stop rather than retrying. Static sites have no process (400 `static_app`). Requires apps:write (Support Plus+). Good habit: start dev before a build session, stop it after.",
41951
+ inputSchema: {
41952
+ appId: external_exports.number().int().positive().describe("The app id from awesomate_app_list / _get"),
41953
+ env: external_exports.enum(["dev", "staging", "prod"]).describe("Which environment to act on"),
41954
+ desiredState: external_exports.enum(["running", "stopped"]).describe("'stopped' frees capacity; 'running' starts it again")
41955
+ }
41956
+ },
41957
+ async ({ appId, env, desiredState }) => {
41958
+ try {
41959
+ return textResult(
41960
+ await hubPost(requireConfig(), `/api/my-apps/apps/${appId}/envs/${env}/run-state`, { desiredState })
41961
+ );
41962
+ } catch (err) {
41963
+ return errorResult(err);
41964
+ }
41965
+ }
41966
+ );
41806
41967
  server.registerTool(
41807
41968
  "awesomate_n8n_provision_pg",
41808
41969
  {
@@ -41871,9 +42032,9 @@ server.registerTool(
41871
42032
  "awesomate_knowledge_sources",
41872
42033
  {
41873
42034
  annotations: annotate("awesomate_knowledge_sources", MUTATING),
41874
- 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.",
41875
42036
  inputSchema: {
41876
- 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"]),
41877
42038
  pathPrefix: external_exports.string().max(512).optional().describe("sync_rule_create: a File Manager folder to keep synced, e.g. 'public/reports'"),
41878
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']"),
41879
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)"),
@@ -41882,6 +42043,7 @@ server.registerTool(
41882
42043
  kind: external_exports.string().max(32).optional().describe("search: source kind filter (web, document, video, audio, image, book, dataset)"),
41883
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"),
41884
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"),
41885
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"),
41886
42048
  removeCollectionIds: external_exports.array(external_exports.string().max(64)).max(16).optional().describe("move: remove from these collections"),
41887
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.26.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",
@@ -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 (see the awesomate-github skill's
62
- §2 deploy wiring needs Awesomate support).
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.
@@ -140,6 +140,14 @@ target; never touch prod without an explicit ask.**
140
140
  addresses from `awesomate_app_get` rather than constructing them — an
141
141
  account without the branded domains falls back to subdomains of its own
142
142
  primary domain (`dev-{app}.{domain}`).
143
+ **If dev is stopped, start it before you deploy to it.** `awesomate_app_get`
144
+ reports each environment's `desired_state`. A stopped environment is off to
145
+ free capacity on the account, not broken. `awesomate_app_run_state` with
146
+ `running` brings it back in seconds, and a health probe against a stopped
147
+ env fails for that reason and no other. **Offer to stop it again when the
148
+ session's work is done**: the account can only run a limited number of app
149
+ processes at once, so an idle dev environment is capacity taken from
150
+ something the user actually needs. Never stop `prod`; the tool refuses it.
143
151
  **Publishing to their WordPress site** (as opposed to an app) has its own
144
152
  tools, and `awesomate_run_wp_cli` is the wrong one for content: its
145
153
  argument gate rejects spaces outright, so a title or tagline can't go
@@ -174,6 +182,47 @@ When the need is "remember/track a list an automation uses" rather than an
174
182
  app with a UI, an n8n data table (awesomate-n8n skill) beats building an app
175
183
  — no hosting slot, no deploy, queryable from workflows directly.
176
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
+
177
226
  ## 3. Hard rules
178
227
 
179
228
  - Non-technical user: explain in plain words, one or two short questions at a
@@ -193,5 +242,8 @@ app with a UI, an n8n data table (awesomate-n8n skill) beats building an app
193
242
  The honest options are an upgrade or asking support (awesomate-support
194
243
  skill) to remove an app.
195
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.
196
248
  - Never print a secret or commit a `.env` (the credentials + github skills
197
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 (for apps built with awesomate-app-builder)
63
-
64
- Node apps from the app builder ship with a GitHub Actions workflow that
65
- deploys on a push to `dev` / `staging` / `main` (dev dev, main → prod).
66
- Once the branches exist, explain it as *"save your work = it goes live"* —
67
- don't lecture on branches.
68
-
69
- **But you cannot finish the wiring yourself, and you should say so.** That
70
- workflow needs `SSH_HOST`, `SSH_USER` and `SSH_PRIVATE_KEY` as repo secrets,
71
- and **no client-reachable tool or route returns those values** — they're
72
- admin-only. `github-setup.mjs --deploy-secrets` only copies them from your
73
- environment, so with nothing there it prints `skip deploy secret …` and the
74
- first deploy run goes red. So:
75
-
76
- 1. Set up the repo, the branches and the commits (§1) all of that works.
77
- 2. Tell the user plainly: *"your code is backed up on GitHub; the automatic
78
- deploy still needs Awesomate to connect it I'll raise that for you."*
79
- Raise it via the **awesomate-support** skill.
80
- 3. **Do not loop on the failing pipeline.** Re-running it, rewriting the
81
- workflow, or pushing again won't produce the missing secrets.
82
-
83
- Static sites have no deploy workflow at all the template is just the page
84
- files so pushing does not publish them either; same handoff.
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` that never clears is usually the unwired deploy
111
- secrets (§2), not a bug you can fix.** Check whether `SSH_HOST` /
112
- `SSH_USER` / `SSH_PRIVATE_KEY` are set on the repo (`gh secret list`)
113
- before touching anything: if they're missing, stop, tell the user the
114
- deploy connection is pending with Awesomate, and raise it via the
115
- awesomate-support skill. Don't retry the run, and don't start editing the
116
- workflow file.
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>] [--deploy-secrets]
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 read from env vars (SSH_HOST/SSH_USER/SSH_PRIVATE_KEY), not
13
- * argv, so they never appear in `ps`. Prints only non-sensitive output.
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>] [--deploy-secrets]');
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